From 7ae885a1140294ce2a68554373d36d73766eb8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 7 May 2026 00:04:06 +0800 Subject: [PATCH] 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. --- AGENTS.md | 2 + .../__tests__/diagnosticsSettings.test.tsx | 36 ++ desktop/src/api/doctor.ts | 75 +++ desktop/src/components/ErrorBoundary.test.tsx | 49 ++ desktop/src/components/ErrorBoundary.tsx | 34 +- .../src/components/chat/composerUtils.test.ts | 1 + desktop/src/components/chat/composerUtils.ts | 2 +- desktop/src/components/doctor/DoctorPanel.tsx | 106 ++++ .../components/layout/StartupErrorView.tsx | 3 + desktop/src/i18n/locales/en.ts | 12 + desktop/src/i18n/locales/zh.ts | 12 + desktop/src/lib/doctorRepair.test.ts | 65 +++ desktop/src/lib/doctorRepair.ts | 82 +++ desktop/src/lib/persistenceMigrations.test.ts | 40 ++ desktop/src/lib/persistenceMigrations.ts | 36 +- desktop/src/pages/DiagnosticsSettings.tsx | 5 + src/server/__tests__/doctor-service.test.ts | 180 +++++++ src/server/api/doctor.ts | 57 ++ src/server/router.ts | 4 + src/server/services/doctorService.ts | 509 ++++++++++++++++++ 20 files changed, 1293 insertions(+), 17 deletions(-) create mode 100644 desktop/src/api/doctor.ts create mode 100644 desktop/src/components/ErrorBoundary.test.tsx create mode 100644 desktop/src/components/doctor/DoctorPanel.tsx create mode 100644 desktop/src/lib/doctorRepair.test.ts create mode 100644 desktop/src/lib/doctorRepair.ts create mode 100644 src/server/__tests__/doctor-service.test.ts create mode 100644 src/server/api/doctor.ts create mode 100644 src/server/services/doctorService.ts diff --git a/AGENTS.md b/AGENTS.md index b211a99e..72e5418a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index 3ee83d53..6c518b2e 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -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() + + 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') + }) }) diff --git a/desktop/src/api/doctor.ts b/desktop/src/api/doctor.ts new file mode 100644 index 00000000..bb50982e --- /dev/null +++ b/desktop/src/api/doctor.ts @@ -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 => { + const [{ report }, { result }] = await Promise.all([ + doctorApi.report(), + doctorApi.repair(), + ]) + + return { + ok: true, + report, + repair: result, + } + }, +} diff --git a/desktop/src/components/ErrorBoundary.test.tsx b/desktop/src/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000..5e4dee7f --- /dev/null +++ b/desktop/src/components/ErrorBoundary.test.tsx @@ -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 }) => ( +
{compact ? 'compact doctor' : 'doctor'}
+ ), +})) + +function CrashingChild(): never { + throw new Error('boom') +} + +describe('ErrorBoundary', () => { + let consoleErrorSpy: ReturnType + + 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( + + + , + ) + + 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() + }) +}) diff --git a/desktop/src/components/ErrorBoundary.tsx b/desktop/src/components/ErrorBoundary.tsx index 97b192a9..fadb7edc 100644 --- a/desktop/src/components/ErrorBoundary.tsx +++ b/desktop/src/components/ErrorBoundary.tsx @@ -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 { render() { if (this.state.hasError) { - return ( -
-
-
{t('errorBoundary.title')}
-
- {t('errorBoundary.description')} -
-
-
- ) + return } return this.props.children } } + +function ErrorBoundaryFallback() { + return ( +
+
+
{t('errorBoundary.title')}
+
+ {t('errorBoundary.description')} +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index ff39605d..2900a66a 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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') }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 50292392..7a896690 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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' }, diff --git a/desktop/src/components/doctor/DoctorPanel.tsx b/desktop/src/components/doctor/DoctorPanel.tsx new file mode 100644 index 00000000..cb091d86 --- /dev/null +++ b/desktop/src/components/doctor/DoctorPanel.tsx @@ -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(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 ( +
+
+
+
{t('settings.diagnostics.doctorTitle')}
+

+ {t('settings.diagnostics.doctorDescription')} +

+

+ {t('settings.diagnostics.doctorProtectedData')} +

+
+
+ +
+
+ +
+ {t('settings.diagnostics.doctorSafeKeys')} +
+ + {statusText ? ( +
+ {statusText} +
+ ) : null} +
+ ) +} + +function getDoctorToastMessage( + t: ReturnType, + 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, + 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 +} diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx index 7238fcfa..88a820ea 100644 --- a/desktop/src/components/layout/StartupErrorView.tsx +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -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')} + + diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index dfaf9e87..57d030e5 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 37d90367..90f03b90 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -145,6 +145,18 @@ export const zh: Record = { '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': '错误已记录到诊断日志。', diff --git a/desktop/src/lib/doctorRepair.test.ts b/desktop/src/lib/doctorRepair.test.ts new file mode 100644 index 00000000..b9d26d39 --- /dev/null +++ b/desktop/src/lib/doctorRepair.test.ts @@ -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') + }) +}) diff --git a/desktop/src/lib/doctorRepair.ts b/desktop/src/lib/doctorRepair.ts new file mode 100644 index 00000000..4d9bc6db --- /dev/null +++ b/desktop/src/lib/doctorRepair.ts @@ -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 + +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 { + 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), + } + } +} diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 2c53f821..107ca10e 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -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, + ])) + }) }) diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index be47ad7f..a8c4cf7d 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -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 } diff --git a/desktop/src/pages/DiagnosticsSettings.tsx b/desktop/src/pages/DiagnosticsSettings.tsx index c899fe33..ae52b09e 100644 --- a/desktop/src/pages/DiagnosticsSettings.tsx +++ b/desktop/src/pages/DiagnosticsSettings.tsx @@ -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() { +
+ +
+
diff --git a/src/server/__tests__/doctor-service.test.ts b/src/server/__tests__/doctor-service.test.ts new file mode 100644 index 00000000..2333c103 --- /dev/null +++ b/src/server/__tests__/doctor-service.test.ts @@ -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, +): { 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 === '/.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: '/.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: '/.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', + }), + ]), + ) + }) +}) diff --git a/src/server/api/doctor.ts b/src/server/api/doctor.ts new file mode 100644 index 00000000..6fba2dd8 --- /dev/null +++ b/src/server/api/doctor.ts @@ -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 { + 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> { + 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 : {} + } 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') +} diff --git a/src/server/router.ts b/src/server/router.ts index 92dfe07e..a66e778a 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -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 { const path = url.pathname @@ -95,6 +96,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('', 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, ''], + [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 { + 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 { + 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).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('/') +}