Skip to content

Commit 820414c

Browse files
committed
fix(config): strip UTF-8 BOM in preset-switch and update-checker readers (#1028)
1 parent 64491c6 commit 820414c

4 files changed

Lines changed: 144 additions & 4 deletions

File tree

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)