Skip to content

Commit 34bffad

Browse files
authored
Merge pull request #1035 from MyGO-Mujica/fix/strip-utf8-bom-in-config
fix(config): strip UTF-8 BOM before parsing config files (#1028)
2 parents 9632b70 + 820414c commit 34bffad

10 files changed

Lines changed: 204 additions & 7 deletions

File tree

src/cli/config-io.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ describe('config-io', () => {
7676
expect(result.error).toBeUndefined();
7777
});
7878

79+
test('parseConfigFile strips a UTF-8 BOM before parsing', () => {
80+
const path = join(tmpDir, 'bom.json');
81+
writeFileSync(path, `\uFEFF${'{"a": 1}'}`);
82+
const result = parseConfigFile(path);
83+
expect(result.config).toEqual({ a: 1 } as any);
84+
expect(result.error).toBeUndefined();
85+
});
86+
7987
test('parseConfigFile returns null for non-existent file', () => {
8088
const result = parseConfigFile(join(tmpDir, 'nonexistent.json'));
8189
expect(result.config).toBeNull();

src/cli/config-io.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@ export function parseConfigFile(path: string): {
404404
if (!existsSync(path)) return { config: null };
405405
const stat = statSync(path);
406406
if (stat.size === 0) return { config: null };
407-
const content = readFileSync(path, 'utf-8');
407+
// Strip a UTF-8 BOM (RFC 8259 permits one) so JSON.parse does not choke.
408+
const content = readFileSync(path, 'utf-8').replace(/^\uFEFF/, '');
408409
if (content.trim().length === 0) return { config: null };
409410
return { config: JSON.parse(stripJsonComments(content)) as OpenCodeConfig };
410411
} catch (err) {

src/cli/doctor.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,27 @@ describe('runDoctorCheck', () => {
162162
expect(result.configs[1].config?.disabled_tools).toEqual(['webfetch']);
163163
});
164164

165+
test('config with UTF-8 BOM is accepted as valid', () => {
166+
const projectDir = path.join(tempDir, 'project');
167+
const configDir = path.join(projectDir, '.opencode');
168+
fs.mkdirSync(configDir, { recursive: true });
169+
fs.writeFileSync(
170+
path.join(configDir, 'oh-my-opencode-slim.json'),
171+
`\uFEFF${JSON.stringify({
172+
agents: { oracle: { model: 'test/model' } },
173+
})}`,
174+
);
175+
176+
const result = runDoctorCheck(projectDir);
177+
178+
// The BOM is stripped before parsing, so the config is valid rather
179+
// than a false invalid-json diagnosis
180+
expect(result.ok).toBe(true);
181+
expect(result.configs[1].ok).toBe(true);
182+
expect(result.configs[1].error).toBeUndefined();
183+
expect(result.configs[1].config?.agents?.oracle?.model).toBe('test/model');
184+
});
185+
165186
test('invalid JSON returns not ok with invalid-json error', () => {
166187
const projectDir = path.join(tempDir, 'project');
167188
const configDir = path.join(projectDir, '.opencode');

src/cli/doctor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ function checkConfigFile(
8080
};
8181
}
8282

83-
const content = fs.readFileSync(configPath, 'utf-8');
83+
// Strip a UTF-8 BOM so a BOM-prefixed config is not misdiagnosed as
84+
// invalid JSON (matches the loader's behavior).
85+
const content = fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, '');
8486
const rawConfig = JSON.parse(stripJsonComments(content));
8587
// Normalize disabled_* keys exactly like the loader does before schema
8688
// validation, so a string value (e.g. "explorer") is not diagnosed as a

src/config/loader.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ describe('loadPluginConfig', () => {
6666
expect(config.autoUpdate).toBe(false);
6767
});
6868

69+
test('loads config with a UTF-8 BOM prefix (same result as no BOM)', () => {
70+
const projectDir = path.join(tempDir, 'project');
71+
const projectConfigDir = path.join(projectDir, '.opencode');
72+
fs.mkdirSync(projectConfigDir, { recursive: true });
73+
fs.writeFileSync(
74+
path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
75+
`\uFEFF${JSON.stringify({
76+
preset: 'fast',
77+
presets: { fast: { oracle: { model: 'fast-model' } } },
78+
agents: { oracle: { temperature: 0.9 } },
79+
autoUpdate: false,
80+
})}`,
81+
);
82+
83+
const config = loadPluginConfig(projectDir);
84+
85+
// The BOM is stripped silently (RFC 8259 permits one); every setting
86+
// survives, including preset resolution.
87+
expect(config.autoUpdate).toBe(false);
88+
expect(config.agents?.oracle?.model).toBe('fast-model');
89+
expect(config.agents?.oracle?.temperature).toBe(0.9);
90+
});
91+
6992
test('deep-merges webfetch settings across user and project configs', () => {
7093
const userConfigPath = path.join(userConfigDir, 'opencode');
7194
const projectDir = path.join(tempDir, 'project');

src/config/loader.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ function loadConfigFromPath(
165165
options?: LoadPluginConfigOptions,
166166
): PluginConfig | null {
167167
try {
168-
const content = fs.readFileSync(configPath, 'utf-8');
168+
// Strip a UTF-8 BOM (RFC 8259 permits one); JSON.parse would otherwise
169+
// fail with "Unrecognized token" and silently drop the whole config.
170+
const content = fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, '');
169171
// Use stripJsonComments to support JSONC format (comments and trailing commas)
170172
let rawConfig: unknown;
171173
try {

src/hooks/auto-update-checker/checker.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,49 @@ describe('auto-update-checker/checker', () => {
108108
statSpy.mockRestore();
109109
readSpy.mockRestore();
110110
});
111+
112+
test('returns version from a BOM-prefixed config', async () => {
113+
const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
114+
(p: string) => {
115+
if (p.includes('opencode.json')) return true;
116+
if (p.includes('package.json')) return true;
117+
return false;
118+
},
119+
);
120+
const statSpy = spyOn(fs, 'statSync').mockImplementation(
121+
() =>
122+
({
123+
isDirectory: () => true,
124+
}) as unknown as fs.Stats,
125+
);
126+
const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
127+
(p: string) => {
128+
if (p.includes('opencode.json')) {
129+
// A UTF-8 BOM (RFC 8259 permits one) must not break JSON.parse.
130+
return `\uFEFF${JSON.stringify({
131+
plugin: ['file:///dev/oh-my-opencode-slim'],
132+
})}`;
133+
}
134+
if (p.includes('package.json')) {
135+
return JSON.stringify({
136+
name: 'oh-my-opencode-slim',
137+
version: '1.2.3-dev',
138+
});
139+
}
140+
return '';
141+
},
142+
);
143+
144+
const { getLocalDevVersion } = await import(
145+
`./checker?test=${importCounter++}`
146+
);
147+
148+
expect(getLocalDevVersion('/test')).toBe('1.2.3-dev');
149+
150+
existsSpy.mockRestore();
151+
statSpy.mockRestore();
152+
readSpy.mockRestore();
153+
});
111154
});
112155

113156
describe('findPluginEntry', () => {
@@ -135,6 +178,28 @@ describe('auto-update-checker/checker', () => {
135178
readSpy.mockRestore();
136179
});
137180

181+
test('detects entry in a BOM-prefixed config', async () => {
182+
const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
183+
(p: string) => p.includes('opencode.json'),
184+
);
185+
const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
186+
`\uFEFF${JSON.stringify({
187+
plugin: ['oh-my-opencode-slim'],
188+
})}`,
189+
);
190+
191+
const { findPluginEntry } = await import(
192+
`./checker?test=${importCounter++}`
193+
);
194+
195+
const entry = findPluginEntry('/test');
196+
expect(entry).not.toBeNull();
197+
expect(entry?.entry).toBe('oh-my-opencode-slim');
198+
199+
existsSpy.mockRestore();
200+
readSpy.mockRestore();
201+
});
202+
138203
test('detects pinned version entry', async () => {
139204
const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
140205
(p: string) => p.includes('opencode.json'),

src/hooks/auto-update-checker/checker.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,11 @@ function getLocalDevPath(directory: string): string | null {
346346
for (const configPath of getConfigPaths(directory)) {
347347
try {
348348
if (!fs.existsSync(configPath)) continue;
349-
const content = fs.readFileSync(configPath, 'utf-8');
349+
// Strip a UTF-8 BOM (RFC 8259 permits one); JSON.parse would otherwise
350+
// fail with "Unrecognized token" and skip the local dev path.
351+
const content = fs
352+
.readFileSync(configPath, 'utf-8')
353+
.replace(/^\uFEFF/, '');
350354
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
351355
const plugins = getPluginEntries(config);
352356

@@ -436,7 +440,11 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
436440
for (const configPath of getConfigPaths(directory)) {
437441
try {
438442
if (!fs.existsSync(configPath)) continue;
439-
const content = fs.readFileSync(configPath, 'utf-8');
443+
// Strip a UTF-8 BOM (RFC 8259 permits one); JSON.parse would otherwise
444+
// fail with "Unrecognized token" and the plugin entry would be missed.
445+
const content = fs
446+
.readFileSync(configPath, 'utf-8')
447+
.replace(/^\uFEFF/, '');
440448
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
441449
const plugins = getPluginEntries(config);
442450

src/tools/preset-switch.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,39 @@ describe('switchPresetOnDisk', () => {
150150
});
151151
});
152152

153+
test('persists preset name when the user config has a UTF-8 BOM', () => {
154+
const configDir = path.join(tempDir, 'opencode-config');
155+
fs.mkdirSync(configDir, { recursive: true });
156+
process.env.OPENCODE_CONFIG_DIR = configDir;
157+
158+
const configPath = path.join(configDir, 'oh-my-opencode-slim.json');
159+
fs.writeFileSync(
160+
configPath,
161+
`\uFEFF${JSON.stringify({
162+
preset: 'old',
163+
agents: { oracle: { model: 'old-model' } },
164+
})}`,
165+
);
166+
167+
const config: PluginConfig = {
168+
presets: {
169+
cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
170+
},
171+
};
172+
173+
const result = switchPresetOnDisk(tempDir, 'cheap', config);
174+
expect(result.ok).toBe(true);
175+
176+
// The BOM is stripped on read, so the preset name is persisted; the
177+
// rewritten file is plain JSON that parses cleanly.
178+
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as {
179+
preset?: string;
180+
agents?: Record<string, unknown>;
181+
};
182+
expect(persisted.preset).toBe('cheap');
183+
expect(persisted.agents).toEqual({ oracle: { model: 'old-model' } });
184+
});
185+
153186
test('resolves legacy alias keys (explore → explorer)', () => {
154187
const config: PluginConfig = {
155188
presets: {
@@ -287,6 +320,36 @@ describe('writePreset', () => {
287320
expect(persisted.preset).toBe('old');
288321
});
289322

323+
test('reads a user config with a UTF-8 BOM before writing', () => {
324+
const configDir = path.join(tempDir, 'opencode-config');
325+
fs.mkdirSync(configDir, { recursive: true });
326+
process.env.OPENCODE_CONFIG_DIR = configDir;
327+
const configPath = path.join(configDir, 'oh-my-opencode-slim.json');
328+
fs.writeFileSync(
329+
configPath,
330+
`\uFEFF${JSON.stringify({
331+
preset: 'old',
332+
presets: { existing: { oracle: { model: 'a' } } },
333+
})}`,
334+
);
335+
336+
const ok = writePreset(tempDir, 'scout', {
337+
explorer: { model: 'openai/gpt-5.6-luna' },
338+
});
339+
340+
expect(ok).toBe(true);
341+
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as {
342+
preset?: string;
343+
presets?: Record<string, unknown>;
344+
};
345+
// Existing fields survived, proving the BOM-prefixed file was parsed
346+
expect(persisted.preset).toBe('old');
347+
expect(persisted.presets?.existing).toEqual({ oracle: { model: 'a' } });
348+
expect(persisted.presets?.scout).toEqual({
349+
explorer: { model: 'openai/gpt-5.6-luna' },
350+
});
351+
});
352+
290353
test('overwrites an existing preset of the same name', () => {
291354
const configDir = path.join(tempDir, 'opencode-config');
292355
fs.mkdirSync(configDir, { recursive: true });

src/tools/preset-switch.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ function persistPresetName(directory: string, presetName: string): void {
165165
try {
166166
const { userConfigPath } = findPluginConfigPaths(directory);
167167
if (!userConfigPath) return;
168-
const raw = fs.readFileSync(userConfigPath, 'utf-8');
168+
// Strip a UTF-8 BOM (RFC 8259 permits one); JSON.parse would otherwise
169+
// fail with "Unrecognized token" and the preset would not be persisted.
170+
const raw = fs.readFileSync(userConfigPath, 'utf-8').replace(/^\uFEFF/, '');
169171
const persisted = JSON.parse(stripJsonComments(raw)) as Record<
170172
string,
171173
unknown
@@ -186,7 +188,9 @@ function readUserConfig(directory: string): Record<string, unknown> | null {
186188
try {
187189
const { userConfigPath } = findPluginConfigPaths(directory);
188190
if (!userConfigPath) return null;
189-
const raw = fs.readFileSync(userConfigPath, 'utf-8');
191+
// Strip a UTF-8 BOM (RFC 8259 permits one); JSON.parse would otherwise
192+
// fail with "Unrecognized token" and the preset name would be lost.
193+
const raw = fs.readFileSync(userConfigPath, 'utf-8').replace(/^\uFEFF/, '');
190194
return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
191195
} catch {
192196
return null;

0 commit comments

Comments
 (0)