|
1 | 1 | import { readFileSync } from 'fs'; |
2 | | -import { mkdir, readFile, writeFile } from 'fs/promises'; |
| 2 | +import { mkdir, readFile, stat, writeFile } from 'fs/promises'; |
3 | 3 | import { randomUUID } from 'node:crypto'; |
4 | 4 | import { homedir } from 'os'; |
5 | 5 | import { join } from 'path'; |
6 | 6 | import { z } from 'zod'; |
| 7 | +import { toError } from '../../errors/types.js'; |
7 | 8 |
|
8 | 9 | export const GLOBAL_CONFIG_DIR = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); |
9 | 10 | export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json'); |
@@ -46,32 +47,70 @@ export function readGlobalConfigSync(configFile = GLOBAL_CONFIG_FILE): GlobalCon |
46 | 47 | } |
47 | 48 | } |
48 | 49 |
|
| 50 | +export type UpdateGlobalConfigResult = { success: true } | { success: false; error: Error }; |
| 51 | + |
49 | 52 | export async function updateGlobalConfig( |
50 | 53 | partial: GlobalConfig, |
51 | 54 | configDir = GLOBAL_CONFIG_DIR, |
52 | 55 | configFile = GLOBAL_CONFIG_FILE |
53 | | -): Promise<boolean> { |
54 | | - try { |
55 | | - const existing = await readGlobalConfigForUpdate(configFile); |
56 | | - const merged: GlobalConfig = mergeConfig(existing, partial); |
| 56 | +): Promise<UpdateGlobalConfigResult> { |
| 57 | + // Read the existing config strictly: a missing file is fine (start fresh), but a |
| 58 | + // malformed file must not be silently overwritten with merged-in defaults. |
| 59 | + const existing = await loadConfigForUpdate(configFile); |
| 60 | + if (!existing.success) { |
| 61 | + return existing; |
| 62 | + } |
57 | 63 |
|
| 64 | + try { |
| 65 | + const merged: GlobalConfig = mergeConfig(existing.config, partial); |
58 | 66 | await mkdir(configDir, { recursive: true }); |
59 | 67 | await writeFile(configFile, JSON.stringify(merged, null, 2), 'utf-8'); |
60 | | - return true; |
61 | | - } catch { |
62 | | - return false; |
| 68 | + return { success: true }; |
| 69 | + } catch (error) { |
| 70 | + return { success: false, error: new Error(`Failed to write config to ${configFile}: ${toError(error).message}`) }; |
63 | 71 | } |
64 | 72 | } |
65 | 73 |
|
66 | | -async function readGlobalConfigForUpdate(configFile: string): Promise<GlobalConfig> { |
| 74 | +type LoadConfigResult = { success: true; config: GlobalConfig } | { success: false; error: Error }; |
| 75 | + |
| 76 | +/** |
| 77 | + * Reads the existing global config for an update. Distinguishes a missing file |
| 78 | + * (treated as an empty config) from a malformed one (read/parse/schema failure), |
| 79 | + * so the caller can avoid clobbering a config it could not understand. |
| 80 | + */ |
| 81 | +async function loadConfigForUpdate(configFile: string): Promise<LoadConfigResult> { |
| 82 | + const existingFile = await configFileExists(configFile); |
| 83 | + if (!existingFile.success) { |
| 84 | + return existingFile; |
| 85 | + } |
| 86 | + if (!existingFile.exists) { |
| 87 | + return { success: true, config: {} }; |
| 88 | + } |
| 89 | + |
67 | 90 | try { |
68 | 91 | const data = await readFile(configFile, 'utf-8'); |
69 | | - return GlobalConfigSchema.parse(JSON.parse(data)); |
| 92 | + return { success: true, config: GlobalConfigSchema.parse(JSON.parse(data)) }; |
| 93 | + } catch (error) { |
| 94 | + const cause = toError(error); |
| 95 | + return { |
| 96 | + success: false, |
| 97 | + error: new Error(`Config at ${configFile} is malformed: ${cause.message}`, { cause }), |
| 98 | + }; |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +type ConfigFileExistsResult = { success: true; exists: boolean } | { success: false; error: Error }; |
| 103 | + |
| 104 | +async function configFileExists(path: string): Promise<ConfigFileExistsResult> { |
| 105 | + try { |
| 106 | + await stat(path); |
| 107 | + return { success: true, exists: true }; |
70 | 108 | } catch (error) { |
71 | | - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
72 | | - return {}; |
| 109 | + const cause = toError(error); |
| 110 | + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') { |
| 111 | + return { success: true, exists: false }; |
73 | 112 | } |
74 | | - throw error; |
| 113 | + return { success: false, error: new Error(`Could not access config at ${path}: ${cause.message}`, { cause }) }; |
75 | 114 | } |
76 | 115 | } |
77 | 116 |
|
|
0 commit comments