diff --git a/scripts/lib/install/config.js b/scripts/lib/install/config.js index 32c1b47a9..ae723a2fc 100644 --- a/scripts/lib/install/config.js +++ b/scripts/lib/install/config.js @@ -10,7 +10,9 @@ let cachedValidator = null; function readJson(filePath, label) { try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + // Windows PowerShell 5.1 writes a BOM with Set-Content -Encoding UTF8. + const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + return JSON.parse(content); } catch (error) { throw new Error(`Invalid JSON in ${label}: ${error.message}`); } diff --git a/tests/lib/install-config.test.js b/tests/lib/install-config.test.js index 7ff455208..80248db89 100644 --- a/tests/lib/install-config.test.js +++ b/tests/lib/install-config.test.js @@ -106,6 +106,63 @@ function runTests() { } })) passed++; else failed++; + if (test('loads a UTF-8 BOM config without changing its values or source bytes', () => { + const cwd = createTempDir('install-config-'); + + try { + const configPath = path.join(cwd, 'ecc-install.json'); + const value = { version: 1, target: 'cursor', modules: ['rules-core'] }; + writeJson(configPath, value); + const expected = loadInstallConfig(configPath); + const content = `\uFEFF${JSON.stringify(value, null, 2).replace(/\n/g, '\r\n')}\r\n`; + fs.writeFileSync(configPath, content, 'utf8'); + + assert.deepStrictEqual(loadInstallConfig(configPath), expected); + assert.strictEqual(fs.readFileSync(configPath, 'utf8'), content); + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + + if (test('preserves BOM characters inside JSON string values', () => { + const cwd = createTempDir('install-config-'); + + try { + const configPath = path.join(cwd, 'ecc-install.json'); + const options = { note: 'custom\uFEFFvalue' }; + fs.writeFileSync(configPath, `\uFEFF${JSON.stringify({ version: 1, options })}`, 'utf8'); + assert.deepStrictEqual(loadInstallConfig(configPath).options, options); + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + + if (test('still rejects malformed JSON and misplaced BOM characters', () => { + const cwd = createTempDir('install-config-'); + + try { + const configPath = path.join(cwd, 'ecc-install.json'); + for (const content of ['\uFEFF{', ' \uFEFF{"version":1}', '\uFEFF\uFEFF{"version":1}']) { + fs.writeFileSync(configPath, content, 'utf8'); + assert.throws(() => loadInstallConfig(configPath), /Invalid JSON in ecc-install.json/); + } + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + + if (test('validates the schema after reading a UTF-8 BOM config', () => { + const cwd = createTempDir('install-config-'); + + try { + const configPath = path.join(cwd, 'ecc-install.json'); + fs.writeFileSync(configPath, '\uFEFF{"version":2,"target":"ghost-target"}', 'utf8'); + assert.throws(() => loadInstallConfig(configPath), /Invalid install config/); + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + if (test('rejects invalid config schema values', () => { const cwd = createTempDir('install-config-'); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index f2846495f..ed9feceb9 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -1487,6 +1487,29 @@ function runTests() { } })) passed++; else failed++; + for (const explicitConfig of [true, false]) { + if (test(`installs from a UTF-8 BOM config (${explicitConfig ? '--config' : 'auto-detected'})`, () => { + const homeDir = createTempDir('install-apply-bom-home-'); + const projectDir = createTempDir('install-apply-bom-project-'); + const configPath = path.join(projectDir, 'ecc-install.json'); + const content = '\uFEFF{\r\n "version": 1,\r\n "target": "cursor",\r\n "modules": ["rules-core"]\r\n}\r\n'; + + try { + fs.writeFileSync(configPath, content, 'utf8'); + const args = explicitConfig ? ['--config', configPath] : []; + const result = run(args, { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc'))); + const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json')); + assert.deepStrictEqual(state.request.modules, ['rules-core']); + assert.strictEqual(fs.readFileSync(configPath, 'utf8'), content); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + } + if (test('preserves legacy language installs when a project config is present', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');