fix(windsurf): read hooks.json with the BOM-tolerant parser (#4010)

Night-ship rebase of #3670. Route the three Windsurf hooks.json reads through existing parseJsonWithBom so a UTF-8 BOM no longer looks like a corrupt file.

Refs #3670
This commit is contained in:
Alex Newman
2026-09-10 23:24:54 -07:00
committed by GitHub
parent a61b86ddde
commit 7c5cbda303
2 changed files with 40 additions and 3 deletions
@@ -4,6 +4,7 @@ import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { getWorkerHost, getWorkerPort } from '../../shared/worker-utils.js';
import { parseJsonWithBom } from '../../shared/atomic-json.js';
import { DATA_DIR } from '../../shared/paths.js';
import { getBunAbsolutePath as findBunPath, getWorkerServiceAbsolutePath as findWorkerServicePath } from './install-paths.js';
@@ -129,7 +130,7 @@ function mergeAndWriteHooksJson(
let existingConfig: WindsurfHooksJson = { hooks: {} };
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
try {
existingConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
existingConfig = parseJsonWithBom<WindsurfHooksJson>(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
if (!existingConfig.hooks) {
existingConfig.hooks = {};
}
@@ -316,7 +317,7 @@ export function uninstallWindsurfHooks(): number {
}
function removeClaudeMemHookEntries(): void {
const parsed = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8')) as Partial<WindsurfHooksJson>;
const parsed = parseJsonWithBom<Partial<WindsurfHooksJson>>(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
const config: WindsurfHooksJson = { hooks: parsed.hooks ?? {} };
for (const eventName of WINDSURF_HOOK_EVENTS) {
@@ -363,7 +364,7 @@ export function checkWindsurfHooksStatus(): number {
let parsedConfig: Partial<WindsurfHooksJson> | null = null;
try {
parsedConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
parsedConfig = parseJsonWithBom(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
logger.error('WORKER', 'Unable to parse hooks.json', { path: WINDSURF_HOOKS_JSON_PATH }, normalizedError);
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parseJsonWithBom } from '../src/shared/atomic-json.js';
// The installer writes to a fixed path under the real home directory, so a filesystem test would
// touch the developer's own Windsurf config. This asserts against the source instead, the same way
// npm-install-windows-hide.test.ts does for its spawn options.
const SOURCE = readFileSync(
join(import.meta.dir, '../src/services/integrations/WindsurfHooksInstaller.ts'),
'utf8',
);
describe("WindsurfHooksInstaller reads Windsurf's hooks.json", () => {
it('never parses that file with a BOM-blind JSON.parse', () => {
// A hooks.json rewritten by PowerShell 5.1 or a Windows editor carries a UTF-8 BOM. Parsing it
// with JSON.parse throws, and the catch turns a perfectly valid file into "Corrupt hooks.json,
// refusing to overwrite" — so install and uninstall both stop on a file that is not corrupt.
expect(SOURCE).not.toMatch(/JSON\.parse\(\s*readFileSync\(\s*WINDSURF_HOOKS_JSON_PATH/);
});
it('uses the shared BOM-tolerant reader for each of the three reads', () => {
// `[^(]*` rather than `<[^>]*>` so a nested type argument such as
// `<Partial<WindsurfHooksJson>>` still counts.
const uses = SOURCE.match(
/parseJsonWithBom[^(]*\(\s*readFileSync\(\s*WINDSURF_HOOKS_JSON_PATH/g,
);
expect(uses?.length).toBe(3);
});
it('that reader accepts what JSON.parse rejects', () => {
const bommed = '\uFEFF' + JSON.stringify({ hooks: { afterFileEdit: [] } });
expect(() => JSON.parse(bommed)).toThrow();
expect(parseJsonWithBom<{ hooks: Record<string, unknown> }>(bommed).hooks).toBeDefined();
});
});