Skip to content

Commit b07cbe6

Browse files
committed
feat(config): 重构配置管理,添加更新配置功能并实现深度冻结
1 parent 69c0ede commit b07cbe6

4 files changed

Lines changed: 265 additions & 42 deletions

File tree

src/lib/accounts.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import consola from "consola"
22

3-
import { type AccountConfig, getConfig, saveConfig } from "./config"
3+
import { type AccountConfig, getConfig, updateConfig } from "./config"
44

55
export type Account = AccountConfig
66

@@ -16,7 +16,7 @@ function getAccountsSync(): AccountsData {
1616
const config = getConfig()
1717
return {
1818
activeAccountId: config.activeAccountId ?? null,
19-
accounts: config.accounts ?? [],
19+
accounts: (config.accounts ?? []).map((account) => ({ ...account })),
2020
}
2121
}
2222

@@ -31,12 +31,11 @@ export function getAccounts(): Promise<AccountsData> {
3131
* Save accounts data to unified config
3232
*/
3333
async function saveAccounts(data: AccountsData): Promise<void> {
34-
const config = getConfig()
35-
await saveConfig({
34+
await updateConfig((config) => ({
3635
...config,
37-
accounts: data.accounts,
36+
accounts: [...data.accounts],
3837
activeAccountId: data.activeAccountId,
39-
})
38+
}))
4039
}
4140

4241
/**

src/lib/config.ts

Lines changed: 161 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import consola from "consola"
22
import fs from "node:fs"
3+
import path from "node:path"
34

45
import { PATHS } from "./paths"
56

@@ -52,6 +53,13 @@ export interface AppConfig {
5253
activeAccountId?: string | null
5354
}
5455

56+
export type ReadonlyAppConfig = DeepReadonly<AppConfig>
57+
58+
type DeepReadonly<T> =
59+
T extends Array<infer U> ? ReadonlyArray<DeepReadonly<U>>
60+
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
61+
: T
62+
5563
const gpt5ExplorationPrompt = `## Exploration and reading files
5664
- **Think first.** Before any tool call, decide ALL files/resources you will need.
5765
- **Batch everything.** If you need multiple files (even from different places), read them together.
@@ -98,7 +106,8 @@ const defaultConfig: AppConfig = {
98106
activeAccountId: null,
99107
}
100108

101-
let cachedConfig: AppConfig | null = null
109+
let cachedConfig: ReadonlyAppConfig | null = null
110+
let configWriteChain: Promise<void> = Promise.resolve()
102111

103112
const VALID_REASONING_EFFORTS = new Set<ReasoningEffort>([
104113
"none",
@@ -262,42 +271,61 @@ export function mergeConfigWithDefaults(): AppConfig {
262271
|| premiumMultiplierMergeResult.changed
263272
|| usageLogCountModeMergeResult.changed
264273

274+
let effectiveConfig = mergedConfig
265275
if (changed) {
266276
try {
267-
fs.writeFileSync(
268-
PATHS.CONFIG_PATH,
269-
`${JSON.stringify(mergedConfig, null, 2)}\n`,
270-
"utf8",
271-
)
277+
writeConfigAtomicallySync(mergedConfig)
278+
cachedConfig = freezeConfig(mergedConfig)
279+
return cloneConfig(mergedConfig)
272280
} catch (writeError) {
273281
consola.warn(
274282
"Failed to write merged default config values to config file",
275283
writeError,
276284
)
285+
effectiveConfig = config
277286
}
278287
}
279288

280-
cachedConfig = mergedConfig
281-
return mergedConfig
289+
cachedConfig = freezeConfig(effectiveConfig)
290+
return cloneConfig(effectiveConfig)
282291
}
283292

284-
export function getConfig(): AppConfig {
285-
cachedConfig ??= readConfigFromDisk()
293+
export function getConfig(): ReadonlyAppConfig {
294+
cachedConfig ??= freezeConfig(readConfigFromDisk())
286295
return cachedConfig
287296
}
288297

289298
/**
290299
* Save config to disk (async)
291300
*/
292-
export async function saveConfig(config: AppConfig): Promise<void> {
293-
ensureConfigFile()
294-
const normalizedConfig = {
295-
...config,
296-
usageLogCountMode: normalizeUsageLogCountMode(config.usageLogCountMode),
297-
} satisfies AppConfig
298-
cachedConfig = normalizedConfig
299-
const content = `${JSON.stringify(normalizedConfig, null, 2)}\n`
300-
await fs.promises.writeFile(PATHS.CONFIG_PATH, content, "utf8")
301+
export async function saveConfig(
302+
config: AppConfig | ReadonlyAppConfig,
303+
): Promise<void> {
304+
await runConfigWrite(async () => {
305+
const normalizedConfig = normalizeConfig(config)
306+
await writeConfigAtomically(normalizedConfig)
307+
cachedConfig = freezeConfig(normalizedConfig)
308+
})
309+
}
310+
311+
export async function updateConfig(
312+
updater: (
313+
config: ReadonlyAppConfig,
314+
) => AppConfig | ReadonlyAppConfig | Promise<AppConfig | ReadonlyAppConfig>,
315+
): Promise<ReadonlyAppConfig> {
316+
let nextConfigSnapshot!: ReadonlyAppConfig
317+
318+
await runConfigWrite(async () => {
319+
const currentConfig = getMutableConfigSnapshot()
320+
const readonlyConfig = freezeConfig(currentConfig)
321+
const updatedConfig = await updater(readonlyConfig)
322+
const normalizedConfig = normalizeConfig(updatedConfig)
323+
await writeConfigAtomically(normalizedConfig)
324+
nextConfigSnapshot = freezeConfig(normalizedConfig)
325+
cachedConfig = nextConfigSnapshot
326+
})
327+
328+
return nextConfigSnapshot
301329
}
302330

303331
export function getExtraPromptForModel(model: string): string {
@@ -340,3 +368,117 @@ export function getAnthropicApiKey(): string | undefined {
340368
export function getUsageLogCountMode(): UsageLogCountMode {
341369
return normalizeUsageLogCountMode(getConfig().usageLogCountMode)
342370
}
371+
372+
function normalizeConfig(config: AppConfig | ReadonlyAppConfig): AppConfig {
373+
return {
374+
...cloneConfig(config),
375+
usageLogCountMode: normalizeUsageLogCountMode(config.usageLogCountMode),
376+
} satisfies AppConfig
377+
}
378+
379+
function cloneConfig(config: AppConfig | ReadonlyAppConfig): AppConfig {
380+
return structuredClone(config) as AppConfig
381+
}
382+
383+
function freezeConfig(config: AppConfig): ReadonlyAppConfig {
384+
const clonedConfig = cloneConfig(config)
385+
return deepFreeze(clonedConfig) as ReadonlyAppConfig
386+
}
387+
388+
function deepFreeze<T>(value: T): T {
389+
if (typeof value !== "object" || value === null) {
390+
return value
391+
}
392+
393+
const propertyNames = Reflect.ownKeys(value)
394+
for (const propertyName of propertyNames) {
395+
const propertyValue = (value as Record<PropertyKey, unknown>)[propertyName]
396+
if (typeof propertyValue === "object" && propertyValue !== null) {
397+
deepFreeze(propertyValue)
398+
}
399+
}
400+
401+
return Object.freeze(value)
402+
}
403+
404+
function getMutableConfigSnapshot(): AppConfig {
405+
return cloneConfig(cachedConfig ?? readConfigFromDisk())
406+
}
407+
408+
function runConfigWrite(task: () => Promise<void>): Promise<void> {
409+
const run = configWriteChain.then(task)
410+
configWriteChain = run.catch(() => {})
411+
return run
412+
}
413+
414+
async function writeConfigAtomically(config: AppConfig): Promise<void> {
415+
ensureConfigFile()
416+
const content = `${JSON.stringify(config, null, 2)}\n`
417+
const tempPath = buildTempConfigPath()
418+
419+
await fs.promises.writeFile(tempPath, content, "utf8")
420+
try {
421+
await fs.promises.chmod(tempPath, 0o600)
422+
} catch {
423+
// Ignore chmod failures on unsupported platforms.
424+
}
425+
426+
try {
427+
await fs.promises.rename(tempPath, PATHS.CONFIG_PATH)
428+
} catch (error) {
429+
if (isRenameReplaceError(error)) {
430+
await fs.promises.rm(PATHS.CONFIG_PATH, { force: true })
431+
await fs.promises.rename(tempPath, PATHS.CONFIG_PATH)
432+
} else {
433+
throw error
434+
}
435+
} finally {
436+
await fs.promises.rm(tempPath, { force: true }).catch(() => {})
437+
}
438+
}
439+
440+
function writeConfigAtomicallySync(config: AppConfig): void {
441+
ensureConfigFile()
442+
const content = `${JSON.stringify(config, null, 2)}\n`
443+
const tempPath = buildTempConfigPath()
444+
445+
fs.writeFileSync(tempPath, content, "utf8")
446+
try {
447+
fs.chmodSync(tempPath, 0o600)
448+
} catch {
449+
// Ignore chmod failures on unsupported platforms.
450+
}
451+
452+
try {
453+
fs.renameSync(tempPath, PATHS.CONFIG_PATH)
454+
} catch (error) {
455+
if (isRenameReplaceError(error)) {
456+
fs.rmSync(PATHS.CONFIG_PATH, { force: true })
457+
fs.renameSync(tempPath, PATHS.CONFIG_PATH)
458+
} else {
459+
throw error
460+
}
461+
} finally {
462+
try {
463+
fs.rmSync(tempPath, { force: true })
464+
} catch {
465+
// Ignore cleanup failures.
466+
}
467+
}
468+
}
469+
470+
function buildTempConfigPath(): string {
471+
return path.join(
472+
PATHS.APP_DIR,
473+
`config.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`,
474+
)
475+
}
476+
477+
function isRenameReplaceError(error: unknown): boolean {
478+
if (!error || typeof error !== "object") {
479+
return false
480+
}
481+
482+
const code = (error as { code?: unknown }).code
483+
return code === "EEXIST" || code === "EPERM"
484+
}

0 commit comments

Comments
 (0)