cc-haha/src/server/services/settingsService.ts
程序员阿江(Relakkes) 0c6d2754b4 feat: add Desktop UI backend server with full REST API and WebSocket support
Add complete server-side implementation for the Claude Code Desktop App UI:

- REST API: sessions, conversations, settings, models, scheduled tasks,
  search, agents, and status endpoints (9 modules, 30+ endpoints)
- WebSocket: real-time chat streaming with state transitions, ping/pong,
  permission request forwarding, and stop generation support
- Services: sessionService (JSONL read/write, CLI-compatible),
  settingsService (atomic writes), cronService, searchService (ripgrep),
  agentService (YAML management)
- Middleware: CORS (localhost-only), auth, unified error handling
- Tests: 180 tests (unit + E2E + business flow), all passing
- Docs: PRD, UI design spec, server architecture design

Non-invasive: all new code under src/server/, no changes to existing CLI code.
CLI/UI data interop: reads/writes the same JSONL/JSON files as the CLI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:23:44 +08:00

152 lines
5.0 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 * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
const VALID_PERMISSION_MODES = [
'default',
'acceptEdits',
'plan',
'bypassPermissions',
'dontAsk',
] as const
export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]
export class SettingsService {
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>> {
try {
const raw = await fs.readFile(filePath, 'utf-8')
return JSON.parse(raw) as Record<string, unknown>
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return {}
}
throw ApiError.internal(`Failed to read settings from ${filePath}: ${err}`)
}
}
/** 获取合并后的设置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 writeJsonFile(
filePath: string,
data: Record<string, unknown>,
): Promise<void> {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
const tmpFile = `${filePath}.tmp.${Date.now()}`
try {
await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf-8')
await fs.rename(tmpFile, filePath)
} catch (err) {
// 清理临时文件best-effort
await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write settings to ${filePath}: ${err}`)
}
}
/** 更新用户级设置(浅合并) */
async updateUserSettings(settings: Record<string, unknown>): Promise<void> {
const current = await this.getUserSettings()
const merged = Object.assign({}, current, settings)
await this.writeJsonFile(this.getUserSettingsPath(), merged)
}
/** 更新项目级设置(浅合并) */
async updateProjectSettings(
settings: Record<string, unknown>,
projectRoot?: string,
): Promise<void> {
const filePath = this.getProjectSettingsPath(projectRoot)
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()
return (settings.defaultMode as string) || '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 })
}
}