mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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.
This commit is contained in:
parent
fa47adc33a
commit
7ae885a114
@ -44,6 +44,8 @@ Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tes
|
||||
## Persistent Storage Compatibility
|
||||
- Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration, an old-fixture regression test, and a persistence upgrade gate.
|
||||
- `~/.claude/settings.json` is user-owned shared state: preserve unknown fields on read/write, merge additively, and never write a repo-owned global `schemaVersion` into it.
|
||||
- Desktop Doctor and any automatic repair path must be deny-by-default. One-click repair may only mutate allowlisted, regenerable desktop UI state such as `cc-haha-*` `localStorage` keys or native window state. It must never mutate chat transcripts, model/provider config, Skills, MCP config, plugin state, IM bindings, adapter sessions, OAuth tokens, or team/session records.
|
||||
- Protected files include `~/.claude/projects/**/*.jsonl`, `~/.claude/settings.json`, project `.claude/settings.json`, `~/.claude/cc-haha/providers.json`, `~/.claude/cc-haha/settings.json`, `~/.claude/adapters.json`, `~/.claude/adapter-sessions.json`, `~/.claude/skills`, project `.claude/skills`, `.mcp.json`, managed MCP config, `~/.claude/plugins/**`, `~/.claude/teams/**`, and `~/.claude/cc-haha/*oauth*.json`. Doctor may diagnose these paths only with redaction unless a future task explicitly adds a reviewed, backup-first manual repair flow.
|
||||
- If a persistence shape cannot be upgraded in place, the change is blocked until the upgrade path is explicit and tested.
|
||||
|
||||
## Feature Quality Contract
|
||||
|
||||
@ -14,10 +14,16 @@ const diagnosticsApiMock = vi.hoisted(() => ({
|
||||
clear: vi.fn(),
|
||||
}))
|
||||
|
||||
const doctorRepairMock = vi.hoisted(() => ({
|
||||
runDoctorRepair: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/diagnostics', () => ({
|
||||
diagnosticsApi: diagnosticsApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/doctorRepair', () => doctorRepairMock)
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
@ -120,6 +126,17 @@ describe('Settings > Diagnostics tab', () => {
|
||||
})
|
||||
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
|
||||
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
|
||||
doctorRepairMock.runDoctorRepair.mockResolvedValue({
|
||||
local: {
|
||||
removedKeys: ['cc-haha-open-tabs', 'cc-haha-session-runtime'],
|
||||
missingKeys: ['cc-haha-theme', 'cc-haha-locale', 'cc-haha.persistence.schemaVersion'],
|
||||
failedKeys: [],
|
||||
},
|
||||
server: {
|
||||
ok: true,
|
||||
},
|
||||
serverError: null,
|
||||
})
|
||||
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
|
||||
@ -190,4 +207,23 @@ describe('Settings > Diagnostics tab', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('runs Doctor from Diagnostics without clearing unrelated desktop state', async () => {
|
||||
window.localStorage.setItem('cc-haha-open-tabs', '{"activeTabId":"__settings__"}')
|
||||
window.localStorage.setItem('cc-haha-theme', 'dark')
|
||||
window.localStorage.setItem('cc-haha-chat-history', 'keep')
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(doctorRepairMock.runDoctorRepair).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toContain('Doctor')
|
||||
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep')
|
||||
})
|
||||
})
|
||||
|
||||
75
desktop/src/api/doctor.ts
Normal file
75
desktop/src/api/doctor.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DoctorReportItem = {
|
||||
id: string
|
||||
label: string
|
||||
kind: 'json' | 'jsonl' | 'directory'
|
||||
scope: 'user' | 'project'
|
||||
path: string
|
||||
protected: boolean
|
||||
exists: boolean
|
||||
status: 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
|
||||
bytes: number
|
||||
entryCount?: number
|
||||
lineCount?: number
|
||||
invalidLineCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DoctorReport = {
|
||||
generatedAt: string
|
||||
items: DoctorReportItem[]
|
||||
protectedSkips: Array<{
|
||||
id: string
|
||||
path: string
|
||||
reason: 'protected'
|
||||
}>
|
||||
summary: {
|
||||
total: number
|
||||
protectedCount: number
|
||||
missingCount: number
|
||||
invalidCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DoctorRepairResult = {
|
||||
dryRun: true
|
||||
mutated: false
|
||||
operations: Array<{
|
||||
id: string
|
||||
path: string
|
||||
action: 'would_repair'
|
||||
}>
|
||||
skips: Array<{
|
||||
id: string
|
||||
path: string
|
||||
reason: 'protected'
|
||||
}>
|
||||
summary: {
|
||||
operationCount: number
|
||||
skipCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DoctorReportRepairResponse = {
|
||||
ok: boolean
|
||||
report: DoctorReport
|
||||
repair: DoctorRepairResult
|
||||
}
|
||||
|
||||
export const doctorApi = {
|
||||
report: () => api.get<{ report: DoctorReport }>('/api/doctor/report', { timeout: 3_000 }),
|
||||
repair: () => api.post<{ result: DoctorRepairResult }>('/api/doctor/repair', {}, { timeout: 3_000 }),
|
||||
reportAndRepair: async (): Promise<DoctorReportRepairResponse> => {
|
||||
const [{ report }, { result }] = await Promise.all([
|
||||
doctorApi.report(),
|
||||
doctorApi.repair(),
|
||||
])
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
repair: result,
|
||||
}
|
||||
},
|
||||
}
|
||||
49
desktop/src/components/ErrorBoundary.test.tsx
Normal file
49
desktop/src/components/ErrorBoundary.test.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
import { reportReactError } from '../lib/diagnosticsCapture'
|
||||
|
||||
vi.mock('../lib/diagnosticsCapture', () => ({
|
||||
reportReactError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./doctor/DoctorPanel', () => ({
|
||||
DoctorPanel: ({ compact }: { compact?: boolean }) => (
|
||||
<div data-testid="doctor-panel">{compact ? 'compact doctor' : 'doctor'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function CrashingChild(): never {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
vi.clearAllMocks()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows retry and compact Doctor fallback when a child crashes', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<CrashingChild />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
|
||||
expect(screen.getByText('The error was recorded in Diagnostics.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('doctor-panel')).toHaveTextContent('compact doctor')
|
||||
expect(reportReactError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { t } from '../i18n'
|
||||
import { reportReactError } from '../lib/diagnosticsCapture'
|
||||
import { Button } from './shared/Button'
|
||||
import { DoctorPanel } from './doctor/DoctorPanel'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
@ -23,18 +25,30 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="text-base font-semibold">{t('errorBoundary.title')}</div>
|
||||
<div className="mt-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('errorBoundary.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ErrorBoundaryFallback />
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorBoundaryFallback() {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="text-base font-semibold">{t('errorBoundary.title')}</div>
|
||||
<div className="mt-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('errorBoundary.description')}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => window.location.reload()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-left">
|
||||
<DoctorPanel compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ describe('composerUtils', () => {
|
||||
|
||||
it('resolves hidden settings aliases without displaying duplicate fallback rows', () => {
|
||||
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
|
||||
expect(resolveSlashUiAction('doctor')).toEqual({ type: 'settings', tab: 'diagnostics' })
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin')
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins')
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ export const PANEL_SLASH_COMMANDS = [
|
||||
|
||||
export const SETTINGS_SLASH_COMMANDS = [
|
||||
{ name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
|
||||
{ name: 'doctor', description: 'Open Doctor in Diagnostics', tab: 'diagnostics' as const },
|
||||
] as const
|
||||
|
||||
export const SLASH_COMMAND_ALIASES = [
|
||||
@ -28,7 +29,6 @@ export const FALLBACK_SLASH_COMMANDS = [
|
||||
{ name: 'init', description: 'Initialize project CLAUDE.md' },
|
||||
{ name: 'bug', description: 'Report a bug' },
|
||||
{ name: 'config', description: 'Open configuration' },
|
||||
{ name: 'doctor', description: 'Diagnose installation issues' },
|
||||
{ name: 'login', description: 'Switch Anthropic accounts' },
|
||||
{ name: 'logout', description: 'Sign out of current account' },
|
||||
{ name: 'memory', description: 'Edit CLAUDE.md memory files' },
|
||||
|
||||
106
desktop/src/components/doctor/DoctorPanel.tsx
Normal file
106
desktop/src/components/doctor/DoctorPanel.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Stethoscope } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../shared/Button'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
|
||||
type DoctorPanelProps = {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function DoctorPanel({ compact = false }: DoctorPanelProps) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<DoctorRepairResult | null>(null)
|
||||
|
||||
const handleRunDoctor = async () => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
const nextResult = await runDoctorRepair()
|
||||
setResult(nextResult)
|
||||
addToast({
|
||||
type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning',
|
||||
message: getDoctorToastMessage(t, nextResult),
|
||||
})
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusText = result ? getDoctorStatusMessage(t, result) : null
|
||||
|
||||
return (
|
||||
<section className={`rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] ${compact ? 'p-3' : 'p-4'} `}>
|
||||
<div className={`flex ${compact ? 'flex-col gap-3' : 'items-start justify-between gap-4'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.doctorTitle')}</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.diagnostics.doctorDescription')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.diagnostics.doctorProtectedData')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex ${compact ? 'justify-start' : 'justify-end'} shrink-0`}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRunDoctor}
|
||||
loading={isRunning}
|
||||
icon={<Stethoscope className="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
{t('settings.diagnostics.runDoctor')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{t('settings.diagnostics.doctorSafeKeys')}
|
||||
</div>
|
||||
|
||||
{statusText ? (
|
||||
<div className="mt-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-2.5 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
{statusText}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function getDoctorToastMessage(
|
||||
t: ReturnType<typeof useTranslation>,
|
||||
result: DoctorRepairResult,
|
||||
): string {
|
||||
if (result.local.failedKeys.length > 0) {
|
||||
return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) })
|
||||
}
|
||||
return t('settings.diagnostics.doctorCompleted')
|
||||
}
|
||||
|
||||
function getDoctorStatusMessage(
|
||||
t: ReturnType<typeof useTranslation>,
|
||||
result: DoctorRepairResult,
|
||||
): string {
|
||||
const clearedCount = result.local.removedKeys.length
|
||||
const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) })
|
||||
|
||||
if (result.local.failedKeys.length > 0) {
|
||||
return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}`
|
||||
}
|
||||
|
||||
if (result.server) {
|
||||
return `${base} ${t('settings.diagnostics.doctorServerRan')}`
|
||||
}
|
||||
|
||||
if (result.serverError) {
|
||||
return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}`
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Copy, RefreshCw } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
import { DoctorPanel } from '../doctor/DoctorPanel'
|
||||
|
||||
const LOG_MARKER = '\n\nRecent server logs:\n'
|
||||
|
||||
@ -92,6 +93,8 @@ export function StartupErrorView({ error }: StartupErrorViewProps) {
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DoctorPanel compact />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -143,6 +143,18 @@ export const en = {
|
||||
'settings.diagnostics.cleared': 'Diagnostics cleared.',
|
||||
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
|
||||
'settings.diagnostics.eventDetails': 'Details',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': 'Safe desktop repair for startup and UI state problems.',
|
||||
'settings.diagnostics.doctorProtectedData': 'Never touches chat history, model config, skills, MCP, IM, or OAuth.',
|
||||
'settings.diagnostics.doctorSafeKeys': 'Clears only: cc-haha-open-tabs, cc-haha-session-runtime, cc-haha-theme, cc-haha-locale, and cc-haha.persistence.schemaVersion.',
|
||||
'settings.diagnostics.runDoctor': 'Run Doctor',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor completed.',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor completed with {count} local cleanup issue(s).',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor failed.',
|
||||
'settings.diagnostics.doctorResultLocal': 'Cleared {count} safe UI key(s).',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '{count} key(s) could not be removed.',
|
||||
'settings.diagnostics.doctorServerRan': 'Server Doctor repair also ran.',
|
||||
'settings.diagnostics.doctorServerUnavailable': 'Server Doctor was unavailable, so only local repair ran.',
|
||||
'errorBoundary.title': 'Something went wrong.',
|
||||
'errorBoundary.description': 'The error was recorded in Diagnostics.',
|
||||
|
||||
|
||||
@ -145,6 +145,18 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.cleared': '诊断日志已清理。',
|
||||
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
|
||||
'settings.diagnostics.eventDetails': '详情',
|
||||
'settings.diagnostics.doctorTitle': 'Doctor',
|
||||
'settings.diagnostics.doctorDescription': '用于启动和界面状态问题的安全桌面修复。',
|
||||
'settings.diagnostics.doctorProtectedData': '绝不会触碰聊天历史、模型配置、Skills、MCP、IM 或 OAuth。',
|
||||
'settings.diagnostics.doctorSafeKeys': '只会清理:cc-haha-open-tabs、cc-haha-session-runtime、cc-haha-theme、cc-haha-locale 和 cc-haha.persistence.schemaVersion。',
|
||||
'settings.diagnostics.runDoctor': '运行 Doctor',
|
||||
'settings.diagnostics.doctorCompleted': 'Doctor 已执行完成。',
|
||||
'settings.diagnostics.doctorPartial': 'Doctor 已完成,但有 {count} 个本地清理项处理失败。',
|
||||
'settings.diagnostics.doctorFailed': 'Doctor 执行失败。',
|
||||
'settings.diagnostics.doctorResultLocal': '已清理 {count} 个安全 UI 键。',
|
||||
'settings.diagnostics.doctorResultFailedKeys': '有 {count} 个键无法移除。',
|
||||
'settings.diagnostics.doctorServerRan': '服务端 Doctor 修复也已执行。',
|
||||
'settings.diagnostics.doctorServerUnavailable': '服务端 Doctor 不可用,因此只执行了本地修复。',
|
||||
'errorBoundary.title': '出现异常。',
|
||||
'errorBoundary.description': '错误已记录到诊断日志。',
|
||||
|
||||
|
||||
65
desktop/src/lib/doctorRepair.test.ts
Normal file
65
desktop/src/lib/doctorRepair.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const doctorApiMock = vi.hoisted(() => ({
|
||||
reportAndRepair: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/doctor', () => ({
|
||||
doctorApi: doctorApiMock,
|
||||
}))
|
||||
|
||||
import { SAFE_DOCTOR_STORAGE_KEYS, runLocalDoctorRepair, runDoctorRepair } from './doctorRepair'
|
||||
|
||||
describe('doctorRepair', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('clears only the safe desktop UI storage keys', () => {
|
||||
window.localStorage.clear()
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
window.localStorage.setItem(key, `${key}-value`)
|
||||
}
|
||||
window.localStorage.setItem('cc-haha-chat-history', 'preserve')
|
||||
window.localStorage.setItem('cc-haha-provider-config', 'preserve')
|
||||
|
||||
const result = runLocalDoctorRepair(window.localStorage)
|
||||
|
||||
expect(result.removedKeys).toEqual(expect.arrayContaining([...SAFE_DOCTOR_STORAGE_KEYS]))
|
||||
expect(result.failedKeys).toEqual([])
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
expect(window.localStorage.getItem(key)).toBeNull()
|
||||
}
|
||||
expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('preserve')
|
||||
expect(window.localStorage.getItem('cc-haha-provider-config')).toBe('preserve')
|
||||
})
|
||||
|
||||
it('keeps local repair non-throwing when storage access is blocked', () => {
|
||||
const storage = {
|
||||
getItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
}
|
||||
|
||||
const result = runLocalDoctorRepair(storage)
|
||||
|
||||
expect(result.removedKeys).toEqual([])
|
||||
expect(result.failedKeys).toEqual(expect.arrayContaining([...SAFE_DOCTOR_STORAGE_KEYS]))
|
||||
})
|
||||
|
||||
it('keeps local repair successful when the server doctor endpoint is unavailable', async () => {
|
||||
window.localStorage.clear()
|
||||
window.localStorage.setItem('cc-haha-theme', 'dark')
|
||||
doctorApiMock.reportAndRepair.mockRejectedValueOnce(new Error('Failed to fetch'))
|
||||
|
||||
const result = await runDoctorRepair({ storage: window.localStorage })
|
||||
|
||||
expect(doctorApiMock.reportAndRepair).toHaveBeenCalled()
|
||||
expect(result.local.removedKeys).toContain('cc-haha-theme')
|
||||
expect(result.server).toBeNull()
|
||||
expect(result.serverError).toBe('Failed to fetch')
|
||||
})
|
||||
})
|
||||
82
desktop/src/lib/doctorRepair.ts
Normal file
82
desktop/src/lib/doctorRepair.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
|
||||
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
|
||||
|
||||
export const SAFE_DOCTOR_STORAGE_KEYS = [
|
||||
'cc-haha-open-tabs',
|
||||
'cc-haha-session-runtime',
|
||||
'cc-haha-theme',
|
||||
'cc-haha-locale',
|
||||
DESKTOP_PERSISTENCE_VERSION_KEY,
|
||||
] as const
|
||||
|
||||
type DoctorStorage = Pick<Storage, 'getItem' | 'removeItem'>
|
||||
|
||||
export type LocalDoctorRepairResult = {
|
||||
removedKeys: string[]
|
||||
missingKeys: string[]
|
||||
failedKeys: string[]
|
||||
}
|
||||
|
||||
export type DoctorRepairResult = {
|
||||
local: LocalDoctorRepairResult
|
||||
server: DoctorReportRepairResponse | null
|
||||
serverError: string | null
|
||||
}
|
||||
|
||||
function getDefaultDoctorStorage(): DoctorStorage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function runLocalDoctorRepair(storage: DoctorStorage | null = getDefaultDoctorStorage()): LocalDoctorRepairResult {
|
||||
if (!storage) {
|
||||
return {
|
||||
removedKeys: [],
|
||||
missingKeys: [...SAFE_DOCTOR_STORAGE_KEYS],
|
||||
failedKeys: [],
|
||||
}
|
||||
}
|
||||
|
||||
const removedKeys: string[] = []
|
||||
const missingKeys: string[] = []
|
||||
const failedKeys: string[] = []
|
||||
|
||||
for (const key of SAFE_DOCTOR_STORAGE_KEYS) {
|
||||
try {
|
||||
if (storage.getItem(key) === null) {
|
||||
missingKeys.push(key)
|
||||
continue
|
||||
}
|
||||
storage.removeItem(key)
|
||||
removedKeys.push(key)
|
||||
} catch {
|
||||
failedKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { removedKeys, missingKeys, failedKeys }
|
||||
}
|
||||
|
||||
export async function runDoctorRepair(options?: {
|
||||
includeServer?: boolean
|
||||
storage?: DoctorStorage | null
|
||||
}): Promise<DoctorRepairResult> {
|
||||
const local = runLocalDoctorRepair(options?.storage)
|
||||
if (options?.includeServer === false) {
|
||||
return { local, server: null, serverError: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const server = await doctorApi.reportAndRepair()
|
||||
return { local, server, serverError: null }
|
||||
} catch (error) {
|
||||
return {
|
||||
local,
|
||||
server: null,
|
||||
serverError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -55,4 +55,44 @@ describe('desktop persistence migrations', () => {
|
||||
expect(window.localStorage.getItem('cc-haha-open-tabs')).toBeNull()
|
||||
expect(window.localStorage.getItem('cc-haha-theme')).toBeNull()
|
||||
})
|
||||
|
||||
test('does not throw if schema version persistence is blocked', () => {
|
||||
const storage = {
|
||||
getItem: window.localStorage.getItem.bind(window.localStorage),
|
||||
removeItem: window.localStorage.removeItem.bind(window.localStorage),
|
||||
setItem: (key: string, value: string) => {
|
||||
if (key === DESKTOP_PERSISTENCE_VERSION_KEY) {
|
||||
throw new Error('storage blocked')
|
||||
}
|
||||
window.localStorage.setItem(key, value)
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => runDesktopPersistenceMigrations(storage)).not.toThrow()
|
||||
expect(runDesktopPersistenceMigrations(storage).migratedKeys).toContain(DESKTOP_PERSISTENCE_VERSION_KEY)
|
||||
})
|
||||
|
||||
test('does not throw if storage reads and writes are blocked', () => {
|
||||
const storage = {
|
||||
getItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
}
|
||||
|
||||
const report = runDesktopPersistenceMigrations(storage)
|
||||
|
||||
expect(report.migratedKeys).toEqual(expect.arrayContaining([
|
||||
'cc-haha-open-tabs',
|
||||
'cc-haha-session-runtime',
|
||||
'cc-haha-theme',
|
||||
'cc-haha-locale',
|
||||
DESKTOP_PERSISTENCE_VERSION_KEY,
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
@ -112,15 +112,39 @@ function normalizeEnumKey(
|
||||
}
|
||||
}
|
||||
|
||||
export function runDesktopPersistenceMigrations(storage: StorageLike | null = globalThis.localStorage ?? null): DesktopMigrationReport {
|
||||
function runMigrationStep(
|
||||
report: DesktopMigrationReport,
|
||||
fallbackKey: string,
|
||||
step: () => void,
|
||||
): void {
|
||||
try {
|
||||
step()
|
||||
} catch {
|
||||
report.migratedKeys.push(fallbackKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultStorage(): StorageLike | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function runDesktopPersistenceMigrations(storage: StorageLike | null = getDefaultStorage()): DesktopMigrationReport {
|
||||
const report: DesktopMigrationReport = { migratedKeys: [] }
|
||||
if (!storage) return report
|
||||
|
||||
migrateTabs(storage, report)
|
||||
migrateSessionRuntime(storage, report)
|
||||
normalizeEnumKey(storage, THEME_STORAGE_KEY, ['light', 'dark'], report)
|
||||
normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report)
|
||||
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
|
||||
runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report))
|
||||
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
|
||||
runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, ['light', 'dark'], report))
|
||||
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report))
|
||||
try {
|
||||
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
|
||||
} catch {
|
||||
report.migratedKeys.push(DESKTOP_PERSISTENCE_VERSION_KEY)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { DoctorPanel } from '../components/doctor/DoctorPanel'
|
||||
|
||||
export function DiagnosticsSettings() {
|
||||
const t = useTranslation()
|
||||
@ -127,6 +128,10 @@ export function DiagnosticsSettings() {
|
||||
<Metric label={t('settings.diagnostics.retention')} value={status ? t('settings.diagnostics.retentionValue', { days: String(status.retentionDays), size: formatBytes(status.maxBytes) }) : '-'} />
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<DoctorPanel />
|
||||
</div>
|
||||
|
||||
<div className="border border-[var(--color-border)] rounded-lg mb-5">
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
180
src/server/__tests__/doctor-service.test.ts
Normal file
180
src/server/__tests__/doctor-service.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleDoctorApi } from '../api/doctor.js'
|
||||
import { handleApiRequest } from '../router.js'
|
||||
import { DoctorService } from '../services/doctorService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let homeDir: string
|
||||
let configDir: string
|
||||
let projectRoot: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'doctor-service-test-'))
|
||||
homeDir = path.join(tmpDir, 'home')
|
||||
configDir = path.join(homeDir, '.claude')
|
||||
projectRoot = path.join(tmpDir, 'workspace', 'demo-project')
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
await fs.mkdir(path.join(configDir, 'projects', 'demo-project'), { recursive: true })
|
||||
await fs.mkdir(path.join(configDir, 'skills', 'alpha-skill'), { recursive: true })
|
||||
await fs.mkdir(path.join(configDir, 'cc-haha'), { recursive: true })
|
||||
await fs.mkdir(path.join(projectRoot, '.claude', 'skills', 'beta-skill'), { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(configDir, 'settings.json'), '{"defaultMode":', 'utf-8')
|
||||
await fs.writeFile(path.join(projectRoot, '.claude', 'settings.json'), '{"model":', 'utf-8')
|
||||
await fs.writeFile(
|
||||
path.join(configDir, 'projects', 'demo-project', 'session-1.jsonl'),
|
||||
'{"type":"message"}\n{bad json}\n',
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(configDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(configDir, 'skills', 'alpha-skill', 'SKILL.md'),
|
||||
'# Alpha\n',
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, '.claude', 'skills', 'beta-skill', 'SKILL.md'),
|
||||
'# Beta\n',
|
||||
'utf-8',
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
urlStr: string,
|
||||
body?: Record<string, unknown>,
|
||||
): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(urlStr, 'http://localhost:3456')
|
||||
const init: RequestInit = { method }
|
||||
if (body) {
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
const req = new Request(url.toString(), init)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
describe('DoctorService', () => {
|
||||
test('report redacts filesystem paths and lists protected skipped items', async () => {
|
||||
const service = new DoctorService({ configDir, homeDir, projectRoot })
|
||||
|
||||
const report = await service.getReport()
|
||||
const serialized = JSON.stringify(report)
|
||||
|
||||
expect(serialized).not.toContain(tmpDir)
|
||||
expect(serialized).not.toContain(projectRoot)
|
||||
|
||||
const userSettings = report.items.find((item) => item.path === '~/.claude/settings.json')
|
||||
expect(userSettings).toBeDefined()
|
||||
expect(userSettings?.protected).toBe(true)
|
||||
expect(userSettings?.status).toBe('invalid_json')
|
||||
|
||||
const projectSettings = report.items.find(
|
||||
(item) => item.path === '<project>/.claude/settings.json',
|
||||
)
|
||||
expect(projectSettings).toBeDefined()
|
||||
expect(projectSettings?.protected).toBe(true)
|
||||
expect(projectSettings?.status).toBe('invalid_json')
|
||||
|
||||
const sessionJsonl = report.items.find(
|
||||
(item) => item.path === '~/.claude/projects/demo-project/session-1.jsonl',
|
||||
)
|
||||
expect(sessionJsonl).toBeDefined()
|
||||
expect(sessionJsonl?.protected).toBe(true)
|
||||
expect(sessionJsonl?.status).toBe('invalid_jsonl')
|
||||
expect(sessionJsonl?.lineCount).toBe(2)
|
||||
expect(sessionJsonl?.invalidLineCount).toBe(1)
|
||||
|
||||
expect(report.protectedSkips).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
|
||||
expect.objectContaining({ path: '<project>/.claude/settings.json', reason: 'protected' }),
|
||||
expect.objectContaining({
|
||||
path: '~/.claude/projects/demo-project/session-1.jsonl',
|
||||
reason: 'protected',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('safe repair skips protected malformed files without modifying them', async () => {
|
||||
const service = new DoctorService({ configDir, homeDir, projectRoot })
|
||||
const userSettingsPath = path.join(configDir, 'settings.json')
|
||||
const projectSettingsPath = path.join(projectRoot, '.claude', 'settings.json')
|
||||
const beforeUser = await fs.readFile(userSettingsPath, 'utf-8')
|
||||
const beforeProject = await fs.readFile(projectSettingsPath, 'utf-8')
|
||||
|
||||
const result = await service.repair()
|
||||
|
||||
expect(result.mutated).toBe(false)
|
||||
expect(result.operations).toEqual([])
|
||||
expect(result.skips).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
|
||||
expect.objectContaining({ path: '<project>/.claude/settings.json', reason: 'protected' }),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(await fs.readFile(userSettingsPath, 'utf-8')).toBe(beforeUser)
|
||||
expect(await fs.readFile(projectSettingsPath, 'utf-8')).toBe(beforeProject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('doctor API', () => {
|
||||
test('returns a report and dry-run repair result', async () => {
|
||||
const reportReq = makeRequest(
|
||||
'GET',
|
||||
`/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
|
||||
)
|
||||
const reportRes = await handleDoctorApi(reportReq.req, reportReq.url, reportReq.segments)
|
||||
expect(reportRes.status).toBe(200)
|
||||
const reportBody = await reportRes.json() as {
|
||||
report: { summary: { protectedCount: number } }
|
||||
}
|
||||
expect(reportBody.report.summary.protectedCount).toBeGreaterThan(0)
|
||||
|
||||
const repairReq = makeRequest('POST', '/api/doctor/repair', { cwd: projectRoot })
|
||||
const repairRes = await handleDoctorApi(repairReq.req, repairReq.url, repairReq.segments)
|
||||
expect(repairRes.status).toBe(200)
|
||||
const repairBody = await repairRes.json() as { result: { mutated: boolean } }
|
||||
expect(repairBody.result.mutated).toBe(false)
|
||||
})
|
||||
|
||||
test('routes doctor requests through the main API router', async () => {
|
||||
const url = new URL(
|
||||
`/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
|
||||
'http://localhost:3456',
|
||||
)
|
||||
const res = await handleApiRequest(new Request(url.toString(), { method: 'GET' }), url)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
report: { items: Array<{ path: string; status: string }> }
|
||||
}
|
||||
expect(body.report.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: '~/.claude/settings.json',
|
||||
status: 'invalid_json',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
57
src/server/api/doctor.ts
Normal file
57
src/server/api/doctor.ts
Normal file
@ -0,0 +1,57 @@
|
||||
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')
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { handleHahaOAuthApi } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
import { handleDoctorApi } from './api/doctor.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -95,6 +96,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'diagnostics':
|
||||
return handleDiagnosticsApi(req, url, segments)
|
||||
|
||||
case 'doctor':
|
||||
return handleDoctorApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
509
src/server/services/doctorService.ts
Normal file
509
src/server/services/doctorService.ts
Normal file
@ -0,0 +1,509 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { diagnosticsService } from './diagnosticsService.js'
|
||||
|
||||
export type DoctorItemKind = 'json' | 'jsonl' | 'directory'
|
||||
export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
|
||||
export type DoctorSkipReason = 'protected'
|
||||
|
||||
export type DoctorReportItem = {
|
||||
id: string
|
||||
label: string
|
||||
kind: DoctorItemKind
|
||||
scope: 'user' | 'project'
|
||||
path: string
|
||||
protected: boolean
|
||||
exists: boolean
|
||||
status: DoctorItemStatus
|
||||
bytes: number
|
||||
entryCount?: number
|
||||
lineCount?: number
|
||||
invalidLineCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DoctorProtectedSkip = {
|
||||
id: string
|
||||
path: string
|
||||
reason: DoctorSkipReason
|
||||
}
|
||||
|
||||
export type DoctorReport = {
|
||||
generatedAt: string
|
||||
items: DoctorReportItem[]
|
||||
protectedSkips: DoctorProtectedSkip[]
|
||||
summary: {
|
||||
total: number
|
||||
protectedCount: number
|
||||
missingCount: number
|
||||
invalidCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DoctorRepairOperation = {
|
||||
id: string
|
||||
path: string
|
||||
action: 'would_repair'
|
||||
}
|
||||
|
||||
export type DoctorRepairSkip = {
|
||||
id: string
|
||||
path: string
|
||||
reason: DoctorSkipReason
|
||||
}
|
||||
|
||||
export type DoctorRepairResult = {
|
||||
dryRun: true
|
||||
mutated: false
|
||||
operations: DoctorRepairOperation[]
|
||||
skips: DoctorRepairSkip[]
|
||||
summary: {
|
||||
operationCount: number
|
||||
skipCount: number
|
||||
}
|
||||
}
|
||||
|
||||
type DoctorServiceOptions = {
|
||||
configDir?: string
|
||||
homeDir?: string
|
||||
projectRoot?: string
|
||||
}
|
||||
|
||||
type DoctorTarget = {
|
||||
id: string
|
||||
label: string
|
||||
kind: DoctorItemKind
|
||||
scope: 'user' | 'project'
|
||||
filePath: string
|
||||
protected: true
|
||||
}
|
||||
|
||||
export class DoctorService {
|
||||
private readonly configDir: string
|
||||
private readonly homeDir: string
|
||||
private readonly projectRoot?: string
|
||||
private readonly usesConfigDirOverride: boolean
|
||||
|
||||
constructor(options: DoctorServiceOptions = {}) {
|
||||
this.configDir = options.configDir || getClaudeConfigHomeDir()
|
||||
this.homeDir = options.homeDir || inferHomeDir(this.configDir)
|
||||
this.projectRoot = options.projectRoot
|
||||
this.usesConfigDirOverride = Boolean(options.configDir || process.env.CLAUDE_CONFIG_DIR)
|
||||
}
|
||||
|
||||
async getReport(): Promise<DoctorReport> {
|
||||
const targets = await this.buildTargets()
|
||||
const items = await Promise.all(targets.map((target) => this.inspectTarget(target)))
|
||||
const protectedSkips = items
|
||||
.filter((item) => item.protected)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
reason: 'protected' as const,
|
||||
}))
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
items,
|
||||
protectedSkips,
|
||||
summary: {
|
||||
total: items.length,
|
||||
protectedCount: protectedSkips.length,
|
||||
missingCount: items.filter((item) => item.status === 'missing').length,
|
||||
invalidCount: items.filter((item) =>
|
||||
item.status === 'invalid_json' ||
|
||||
item.status === 'invalid_jsonl' ||
|
||||
item.status === 'unreadable'
|
||||
).length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async repair(targetIds?: string[]): Promise<DoctorRepairResult> {
|
||||
const report = await this.getReport()
|
||||
const selectedIds = targetIds?.length ? new Set(targetIds) : null
|
||||
const selectedItems = selectedIds
|
||||
? report.items.filter((item) => selectedIds.has(item.id))
|
||||
: report.items
|
||||
|
||||
const operations: DoctorRepairOperation[] = []
|
||||
const skips = selectedItems.map((item) => {
|
||||
if (!item.protected && item.status !== 'ok') {
|
||||
operations.push({
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
action: 'would_repair',
|
||||
})
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
reason: 'protected' as const,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
dryRun: true,
|
||||
mutated: false,
|
||||
operations,
|
||||
skips,
|
||||
summary: {
|
||||
operationCount: operations.length,
|
||||
skipCount: skips.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async buildTargets(): Promise<DoctorTarget[]> {
|
||||
const targets: DoctorTarget[] = [
|
||||
this.jsonTarget('user-settings', 'User settings', 'user', path.join(this.configDir, 'settings.json')),
|
||||
this.jsonTarget(
|
||||
'cc-haha-providers',
|
||||
'Managed providers',
|
||||
'user',
|
||||
path.join(this.configDir, 'cc-haha', 'providers.json'),
|
||||
),
|
||||
this.jsonTarget(
|
||||
'cc-haha-settings',
|
||||
'Managed provider settings',
|
||||
'user',
|
||||
path.join(this.configDir, 'cc-haha', 'settings.json'),
|
||||
),
|
||||
this.jsonTarget('adapters', 'Adapters config', 'user', path.join(this.configDir, 'adapters.json')),
|
||||
this.jsonTarget(
|
||||
'adapter-sessions',
|
||||
'Adapter sessions',
|
||||
'user',
|
||||
path.join(this.configDir, 'adapter-sessions.json'),
|
||||
),
|
||||
this.directoryTarget('user-skills', 'User skills', 'user', path.join(this.configDir, 'skills')),
|
||||
this.directoryTarget('teams', 'Teams', 'user', path.join(this.configDir, 'teams')),
|
||||
this.directoryTarget('plugins', 'Plugins', 'user', path.join(this.configDir, 'plugins')),
|
||||
this.directoryTarget(
|
||||
'cowork-plugins',
|
||||
'Cowork plugins',
|
||||
'user',
|
||||
path.join(this.configDir, 'cowork_plugins'),
|
||||
),
|
||||
this.jsonTarget('user-mcp', 'User MCP config', 'user', this.getUserMcpConfigPath()),
|
||||
this.jsonTarget('oauth', 'OAuth tokens', 'user', path.join(this.configDir, 'cc-haha', 'oauth.json')),
|
||||
this.jsonTarget(
|
||||
'openai-oauth',
|
||||
'OpenAI OAuth tokens',
|
||||
'user',
|
||||
path.join(this.configDir, 'cc-haha', 'openai-oauth.json'),
|
||||
),
|
||||
]
|
||||
|
||||
if (this.projectRoot) {
|
||||
targets.push(
|
||||
this.jsonTarget(
|
||||
'project-settings',
|
||||
'Project settings',
|
||||
'project',
|
||||
path.join(this.projectRoot, '.claude', 'settings.json'),
|
||||
),
|
||||
this.directoryTarget(
|
||||
'project-skills',
|
||||
'Project skills',
|
||||
'project',
|
||||
path.join(this.projectRoot, '.claude', 'skills'),
|
||||
),
|
||||
this.jsonTarget(
|
||||
'project-mcp',
|
||||
'Project MCP config',
|
||||
'project',
|
||||
path.join(this.projectRoot, '.mcp.json'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const sessionFiles = await this.listJsonlFiles(path.join(this.configDir, 'projects'))
|
||||
for (const filePath of sessionFiles) {
|
||||
const relativePath = toPosix(path.relative(this.configDir, filePath))
|
||||
targets.push(
|
||||
this.jsonlTarget(
|
||||
`session-jsonl:${relativePath}`,
|
||||
'Session transcript',
|
||||
'user',
|
||||
filePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
private async inspectTarget(target: DoctorTarget): Promise<DoctorReportItem> {
|
||||
switch (target.kind) {
|
||||
case 'json':
|
||||
return this.inspectJsonTarget(target)
|
||||
case 'jsonl':
|
||||
return this.inspectJsonlTarget(target)
|
||||
case 'directory':
|
||||
return this.inspectDirectoryTarget(target)
|
||||
default:
|
||||
return this.inspectMissingTarget(target)
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectJsonTarget(target: DoctorTarget): Promise<DoctorReportItem> {
|
||||
const exists = await this.pathExists(target.filePath)
|
||||
if (!exists) return this.inspectMissingTarget(target)
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(target.filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
return this.inspectUnreadableTarget(target, error)
|
||||
}
|
||||
const bytes = Buffer.byteLength(raw, 'utf-8')
|
||||
if (!raw.trim()) {
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'invalid_json',
|
||||
bytes,
|
||||
error: 'Empty JSON file',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'ok',
|
||||
bytes,
|
||||
entryCount: countJsonEntries(parsed),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'invalid_json',
|
||||
bytes,
|
||||
error: this.sanitizeText(error instanceof Error ? error.message : String(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectJsonlTarget(target: DoctorTarget): Promise<DoctorReportItem> {
|
||||
const exists = await this.pathExists(target.filePath)
|
||||
if (!exists) return this.inspectMissingTarget(target)
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(target.filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
return this.inspectUnreadableTarget(target, error)
|
||||
}
|
||||
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
let invalidLineCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
JSON.parse(line)
|
||||
} catch {
|
||||
invalidLineCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: invalidLineCount > 0 ? 'invalid_jsonl' : 'ok',
|
||||
bytes: Buffer.byteLength(raw, 'utf-8'),
|
||||
lineCount: lines.length,
|
||||
invalidLineCount,
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectDirectoryTarget(target: DoctorTarget): Promise<DoctorReportItem> {
|
||||
const exists = await this.pathExists(target.filePath)
|
||||
if (!exists) return this.inspectMissingTarget(target)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(target.filePath, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
return this.inspectUnreadableTarget(target, error)
|
||||
}
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'ok',
|
||||
bytes: 0,
|
||||
entryCount: countVisibleEntries(entries),
|
||||
}
|
||||
}
|
||||
|
||||
private inspectMissingTarget(target: DoctorTarget): DoctorReportItem {
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: false,
|
||||
status: 'missing',
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
private inspectUnreadableTarget(target: DoctorTarget, error: unknown): DoctorReportItem {
|
||||
return {
|
||||
...this.baseItem(target),
|
||||
exists: true,
|
||||
status: 'unreadable',
|
||||
bytes: 0,
|
||||
error: this.sanitizeText(error instanceof Error ? error.message : String(error)),
|
||||
}
|
||||
}
|
||||
|
||||
private baseItem(target: DoctorTarget): Omit<DoctorReportItem, 'exists' | 'status' | 'bytes'> {
|
||||
return {
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
kind: target.kind,
|
||||
scope: target.scope,
|
||||
path: this.sanitizePath(target.filePath),
|
||||
protected: target.protected,
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePath(filePath: string): string {
|
||||
if (this.projectRoot && isWithinRoot(filePath, this.projectRoot)) {
|
||||
return this.withAlias('<project>', filePath, this.projectRoot)
|
||||
}
|
||||
if (isWithinRoot(filePath, this.configDir)) {
|
||||
return this.withAlias('~/.claude', filePath, this.configDir)
|
||||
}
|
||||
if (isWithinRoot(filePath, this.homeDir)) {
|
||||
return this.withAlias('~', filePath, this.homeDir)
|
||||
}
|
||||
return this.sanitizeText(filePath)
|
||||
}
|
||||
|
||||
private sanitizeText(value: string): string {
|
||||
let sanitized = diagnosticsService.sanitizeString(value)
|
||||
const replacements: Array<[string | undefined, string]> = [
|
||||
[this.projectRoot, '<project>'],
|
||||
[this.configDir, '~/.claude'],
|
||||
[this.homeDir, '~'],
|
||||
]
|
||||
|
||||
for (const [from, to] of replacements) {
|
||||
if (!from) continue
|
||||
sanitized = sanitized.split(from).join(to)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
private getUserMcpConfigPath(): string {
|
||||
if (this.usesConfigDirOverride) {
|
||||
return path.join(this.configDir, '.claude.json')
|
||||
}
|
||||
return path.join(this.homeDir, '.claude.json')
|
||||
}
|
||||
|
||||
private async listJsonlFiles(rootDir: string): Promise<string[]> {
|
||||
const exists = await this.pathExists(rootDir)
|
||||
if (!exists) return []
|
||||
|
||||
const results: string[] = []
|
||||
const stack = [rootDir]
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) continue
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && fullPath.endsWith('.jsonl')) {
|
||||
results.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((left, right) => left.localeCompare(right))
|
||||
return results
|
||||
}
|
||||
|
||||
private async pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private jsonTarget(
|
||||
id: string,
|
||||
label: string,
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'json', scope, filePath, protected: true }
|
||||
}
|
||||
|
||||
private jsonlTarget(
|
||||
id: string,
|
||||
label: string,
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'jsonl', scope, filePath, protected: true }
|
||||
}
|
||||
|
||||
private directoryTarget(
|
||||
id: string,
|
||||
label: string,
|
||||
scope: 'user' | 'project',
|
||||
filePath: string,
|
||||
): DoctorTarget {
|
||||
return { id, label, kind: 'directory', scope, filePath, protected: true }
|
||||
}
|
||||
|
||||
private withAlias(alias: string, filePath: string, root: string): string {
|
||||
const relativePath = path.relative(root, filePath)
|
||||
if (!relativePath) return alias
|
||||
return `${alias}/${toPosix(relativePath)}`
|
||||
}
|
||||
}
|
||||
|
||||
function countJsonEntries(value: unknown): number {
|
||||
if (Array.isArray(value)) return value.length
|
||||
if (value && typeof value === 'object') return Object.keys(value as Record<string, unknown>).length
|
||||
if (value === null || value === undefined) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
function countVisibleEntries(entries: Dirent[]): number {
|
||||
return entries.filter((entry) => entry.name !== '.DS_Store').length
|
||||
}
|
||||
|
||||
function inferHomeDir(configDir: string): string {
|
||||
if (path.basename(configDir) === '.claude') {
|
||||
return path.dirname(configDir)
|
||||
}
|
||||
return os.homedir()
|
||||
}
|
||||
|
||||
function isWithinRoot(filePath: string, root: string): boolean {
|
||||
const relativePath = path.relative(root, filePath)
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
function toPosix(value: string): string {
|
||||
return value.split(path.sep).join('/')
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user