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