mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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.
510 lines
14 KiB
TypeScript
510 lines
14 KiB
TypeScript
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('/')
|
|
}
|