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:
程序员阿江(Relakkes) 2026-05-07 00:04:06 +08:00
parent fa47adc33a
commit 7ae885a114
20 changed files with 1293 additions and 17 deletions

View File

@ -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

View File

@ -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
View 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,
}
},
}

View 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()
})
})

View File

@ -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>
)
}

View File

@ -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')
})

View File

@ -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' },

View 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
}

View File

@ -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>

View File

@ -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.',

View File

@ -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': '错误已记录到诊断日志。',

View 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')
})
})

View 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),
}
}
}

View File

@ -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,
]))
})
})

View File

@ -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
}

View File

@ -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>

View 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
View 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')
}

View File

@ -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)

View 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('/')
}