fix(config): isolate plugin list reads from a broken JSONC layer

Plugin listing still called readConfigFile per layer, so one unparseable
project file made GET /api/config/plugins and VS Code listPluginEntries
fail. Reuse readConfigLayer isolation and pin comment-only empty parse
in the VS Code suite.

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-15 06:30:36 +00:00
co-authored by serkraser
parent 8b086343fd
commit 6751c7dc7a
7 changed files with 60 additions and 8 deletions
@@ -72,6 +72,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
- `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). `readConfigLayers` isolates `INVALID_JSONC` per layer: a broken file is omitted from the merge (`{}` for that layer only), recorded on `layerErrors`, and does not block valid sibling layers. Writes still refuse to overwrite the broken file.
- `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files (no JSON value) return `{}`. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config).
- `readConfigLayer(filePath)`: Same parse as `readConfigFile`, but isolates `INVALID_JSONC` to `{ config: {}, error }` so plugin/MCP/agent readers can skip one broken layer without aborting valid siblings. Writes still refuse to overwrite the broken file.
- `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check.
- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. A failed custom or user layer throws `INVALID_JSONC` instead of treating that file as empty. A failed project layer is skipped so a valid user/custom entry can still be found.
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer.
+12 -3
View File
@@ -4,6 +4,7 @@ import path from 'path';
import {
AGENT_SCOPE,
readConfigFile,
readConfigLayer,
writeConfig,
} from './shared.js';
import { isPathSpec } from './plugin-spec.js';
@@ -111,15 +112,23 @@ function readPluginConfigLayers(workingDirectory) {
const customPath = getActiveCustomConfigPath();
const userPath = getPrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory);
const userLayer = readConfigLayer(userPath);
const projectLayer = readConfigLayer(projectPath);
const customLayer = readConfigLayer(customPath);
return {
userConfig: readConfigFile(userPath),
projectConfig: readConfigFile(projectPath),
customConfig: readConfigFile(customPath),
userConfig: userLayer.config,
projectConfig: projectLayer.config,
customConfig: customLayer.config,
paths: {
userPath,
projectPath,
customPath,
},
layerErrors: [
userLayer.error && { path: userPath, code: userLayer.error.code, message: userLayer.error.message },
projectLayer.error && projectPath && { path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message },
customLayer.error && customPath && { path: customPath, code: customLayer.error.code, message: customLayer.error.message },
].filter(Boolean),
};
}
@@ -121,6 +121,24 @@ describe('opencode plugins data layer', () => {
expect(readJson(userConfigPath)).toEqual({});
});
test('lists user plugins when a project layer is unparseable', () => {
const partialProject = [
'{',
' "$schema": "https://opencode.ai/config.json",',
' plugin: ["broken-project-plugin"],',
'}',
'',
].join('\n');
writeJson(userConfigPath, { plugin: ['user-plugin'] });
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
fs.writeFileSync(projectFile, partialProject, 'utf8');
expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['user-plugin']);
expect(fs.readFileSync(projectFile, 'utf8')).toBe(partialProject);
expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false);
});
test('lists entries from user and project layers with scopes and parsed kinds', () => {
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
@@ -644,6 +644,7 @@ export {
parseMdFile,
writeMdFile,
readConfigFile,
readConfigLayer,
isPlainObject,
readConfigLayers,
readConfig,