cc-haha/src/server/services/settingsService.ts
程序员阿江(Relakkes) e292d83282 fix: Prevent cached settings writes from resetting desktop preferences
Desktop settings writes update settings.json through SettingsService, while plugin
state writes use the shared CLI settings cache. Reset the shared cache after a
successful atomic SettingsService write so later plugin updates merge against the
fresh file instead of an older in-memory snapshot.

Constraint: Desktop general settings and plugin state share ~/.claude/settings.json but use different write paths
Rejected: Move plugin writes onto SettingsService | broader API change than needed for this regression
Confidence: high
Scope-risk: narrow
Directive: Keep cache invalidation aligned across all settings.json write paths
Tested: bun test src/server/__tests__/settings.test.ts -t "cached CLI settings"
Tested: bun test src/server/__tests__/settings.test.ts src/server/__tests__/plugins.test.ts
Tested: cd desktop && bun run test -- generalSettings settingsStore pluginsSettings
Tested: bun run check:server
Tested: git diff --check
Not-tested: Full bun run verify still fails on unrelated dirty desktop changed-line coverage and CSS color-mix gate
2026-05-15 10:54:40 +08:00

199 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Settings Service — 读写用户级和项目级设置文件
*
* 设置文件为 JSON 格式:
* - 用户级: ~/.claude/settings.json
* - 项目级: {projectRoot}/.claude/settings.json
*
* 合并策略Object.assign({}, userSettings, projectSettings)
*/
import * as fs from 'fs/promises'
import { randomBytes } from 'node:crypto'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
const VALID_PERMISSION_MODES = [
'default',
'acceptEdits',
'plan',
'bypassPermissions',
'dontAsk',
] as const
export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]
export class SettingsService {
private static writeLocks = new Map<string, Promise<void>>()
private projectRoot?: string
constructor(projectRoot?: string) {
this.projectRoot = projectRoot
}
/** 配置目录,支持通过环境变量覆盖(便于测试) */
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
/** 用户级设置文件路径 */
private getUserSettingsPath(): string {
return path.join(this.getConfigDir(), 'settings.json')
}
/** 项目级设置文件路径 */
private getProjectSettingsPath(projectRoot?: string): string {
const root = projectRoot || this.projectRoot
if (!root) {
throw ApiError.badRequest('Project root is required for project settings')
}
return path.join(root, '.claude', 'settings.json')
}
// ---------------------------------------------------------------------------
// 读取
// ---------------------------------------------------------------------------
/** 安全读取 JSON 文件,文件不存在时返回空对象 */
private async readJsonFile(filePath: string): Promise<Record<string, unknown>> {
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath,
label: 'settings',
defaultValue: {},
normalize: normalizeJsonObject,
})
}
/** 获取合并后的设置user + project */
async getSettings(projectRoot?: string): Promise<Record<string, unknown>> {
const user = await this.getUserSettings()
try {
const project = await this.getProjectSettings(projectRoot)
return Object.assign({}, user, project)
} catch {
// project root 未指定时,仅返回 user settings
return user
}
}
/** 获取用户级设置 */
async getUserSettings(): Promise<Record<string, unknown>> {
return this.readJsonFile(this.getUserSettingsPath())
}
/** 获取项目级设置 */
async getProjectSettings(projectRoot?: string): Promise<Record<string, unknown>> {
return this.readJsonFile(this.getProjectSettingsPath(projectRoot))
}
// ---------------------------------------------------------------------------
// 写入(原子写入:先写临时文件,再 rename
// ---------------------------------------------------------------------------
/** 原子写入 JSON 文件 */
private async withWriteLock<T>(
filePath: string,
task: () => Promise<T>,
): Promise<T> {
const previousWrite = SettingsService.writeLocks.get(filePath) ?? Promise.resolve()
const nextWrite = previousWrite
.catch(() => {})
.then(task)
SettingsService.writeLocks.set(filePath, nextWrite)
try {
return await nextWrite
} finally {
if (SettingsService.writeLocks.get(filePath) === nextWrite) {
SettingsService.writeLocks.delete(filePath)
}
}
}
private async writeJsonFile(
filePath: string,
data: Record<string, unknown>,
): Promise<void> {
const dir = path.dirname(filePath)
const contents = JSON.stringify(data, null, 2) + '\n'
let lastError: unknown
for (let attempt = 0; attempt < 2; attempt++) {
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
try {
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(tmpFile, contents, 'utf-8')
await fs.rename(tmpFile, filePath)
resetSettingsCache()
return
} catch (err) {
lastError = err
await fs.unlink(tmpFile).catch(() => {})
if (
(err as NodeJS.ErrnoException).code !== 'ENOENT' ||
attempt === 1
) {
break
}
}
}
throw ApiError.internal(
`Failed to write settings to ${filePath}: ${lastError}`,
)
}
/** 更新用户级设置(浅合并) */
async updateUserSettings(settings: Record<string, unknown>): Promise<void> {
const filePath = this.getUserSettingsPath()
await this.withWriteLock(filePath, async () => {
const current = await this.readJsonFile(filePath)
const merged = Object.assign({}, current, settings)
await this.writeJsonFile(filePath, merged)
})
}
/** 更新项目级设置(浅合并) */
async updateProjectSettings(
settings: Record<string, unknown>,
projectRoot?: string,
): Promise<void> {
const filePath = this.getProjectSettingsPath(projectRoot)
await this.withWriteLock(filePath, async () => {
const current = await this.readJsonFile(filePath)
const merged = Object.assign({}, current, settings)
await this.writeJsonFile(filePath, merged)
})
}
// ---------------------------------------------------------------------------
// 权限模式
// ---------------------------------------------------------------------------
/** 获取当前权限模式 */
async getPermissionMode(): Promise<string> {
const settings = await this.getUserSettings()
const mode = settings.defaultMode
return typeof mode === 'string' && VALID_PERMISSION_MODES.includes(mode as PermissionMode)
? mode
: 'default'
}
/** 设置权限模式 */
async setPermissionMode(mode: string): Promise<void> {
if (!VALID_PERMISSION_MODES.includes(mode as PermissionMode)) {
throw ApiError.badRequest(
`Invalid permission mode: "${mode}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`,
)
}
await this.updateUserSettings({ defaultMode: mode })
}
}