Skip to content

Commit ee3f033

Browse files
committed
fix: repair incomplete config by merging defaults instead of rejecting
When config.json has valid JSON but is missing required fields (e.g. defaultProvider or providers), merge defaults into the raw object instead of discarding it entirely. This preserves pool accounts, provider configs, and other user data that would otherwise be lost when a single field is missing. Only truly unrecoverable configs (invalid JSON, merge still fails) fall back to backup + default.
1 parent 3c1bd80 commit ee3f033

2 files changed

Lines changed: 35 additions & 6 deletions

File tree

src/config.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,26 @@ export function loadConfig(): OcxConfig {
9393
}
9494
try {
9595
const raw = readFileSync(configPath, "utf-8");
96-
return configSchema.parse(JSON.parse(raw)) as OcxConfig;
96+
const parsed = JSON.parse(raw);
97+
const result = configSchema.safeParse(parsed);
98+
if (result.success) return result.data as OcxConfig;
99+
// Schema validation failed — merge defaults into the raw object instead of
100+
// discarding it entirely, so pool accounts and providers survive a missing
101+
// field like defaultProvider.
102+
const defaults = getDefaultConfig();
103+
const merged = { ...defaults, ...parsed };
104+
// Ensure providers from both sides survive
105+
if (parsed.providers && defaults.providers) {
106+
merged.providers = { ...defaults.providers, ...parsed.providers };
107+
}
108+
const retryResult = configSchema.safeParse(merged);
109+
if (retryResult.success) {
110+
warnConfigRepaired(configPath, result.error);
111+
return retryResult.data as OcxConfig;
112+
}
113+
// Merge couldn't fix it — truly broken config
114+
warnAndBackupInvalidConfig(configPath, result.error);
115+
return getDefaultConfig();
97116
} catch (error) {
98117
warnAndBackupInvalidConfig(configPath, error);
99118
return getDefaultConfig();
@@ -181,6 +200,13 @@ export function removePid(): void {
181200
} catch { /* ignore */ }
182201
}
183202

203+
function warnConfigRepaired(configPath: string, error: z.ZodError): void {
204+
if (warnedConfigFallbacks.has(configPath)) return;
205+
warnedConfigFallbacks.add(configPath);
206+
const fields = error.issues.map(i => i.path.join(".") || "config").join(", ");
207+
console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
208+
}
209+
184210
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
185211
if (warnedConfigFallbacks.has(configPath)) return;
186212
warnedConfigFallbacks.add(configPath);

tests/config.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,21 @@ describe("opencodex config defaults", () => {
7777
}
7878
});
7979

80-
test("backs up structurally invalid config before falling back to defaults", () => {
80+
test("repairs structurally incomplete config by merging defaults instead of rejecting", () => {
8181
writeConfig({ port: 10100 });
8282
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
8383

8484
try {
8585
const loaded = loadConfig();
8686

87-
expect(loaded).toEqual(getDefaultConfig());
87+
// Merge should fill in missing providers and defaultProvider from defaults
88+
expect(loaded.port).toBe(10100);
89+
expect(loaded.defaultProvider).toBe("openai");
90+
expect(loaded.providers).toBeDefined();
91+
// No backup created — config was repaired, not rejected
8892
const backups = backupNames();
89-
expect(backups).toHaveLength(1);
90-
expect(JSON.parse(readFileSync(join(testDir, backups[0]), "utf-8"))).toEqual({ port: 10100 });
91-
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("providers"));
93+
expect(backups).toHaveLength(0);
94+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("repaired"));
9295
} finally {
9396
errorSpy.mockRestore();
9497
}

0 commit comments

Comments
 (0)