fix: use jsonc-parser to preserve comments in opencode.json during setup

- Add mergeJsoncFile() using parseTree/modify/applyEdits pipeline
- Add getOpenCodeMcpEntry() for OpenCode MCP format { type: local, command: [...] }
- Replace readJsonFile+writeJsonFile in setupOpenCode with mergeJsoncFile
- Fix wipe bug: JSON.parse on JSONC comments caused catch block to reset config to {}
- Add 9 tests for JSONC comment preservation, corrupt file safety, and format
This commit is contained in:
Tom Hale 2026-04-21 01:27:01 +07:00
parent 9dc733adf7
commit e961c5f073
2 changed files with 328 additions and 6 deletions

View file

@ -13,6 +13,7 @@ import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import { parseTree, modify, applyEdits } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
const __filename = fileURLToPath(import.meta.url);
@ -75,6 +76,23 @@ function getMcpEntry() {
};
}
/**
* OpenCode uses a different MCP format: { type: "local", command: [...] }
* where command is a flat array (command + args combined).
*/
function getOpenCodeMcpEntry() {
const bin = resolveGitnexusBin();
if (bin) {
return { type: 'local', command: [bin, 'mcp'] };
}
if (process.platform === 'win32') {
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
}
return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
}
/**
* Merge gitnexus entry into an existing MCP config JSON object.
* Returns the updated config.
@ -110,6 +128,64 @@ async function writeJsonFile(filePath: string, data: any): Promise<void> {
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
/**
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
* Falls back to readJsonFile + writeJsonFile for non-JSONC files that are valid JSON.
* If the file is genuinely corrupt (not valid JSON or JSONC), leaves it untouched.
*/
async function mergeJsoncFile(
filePath: string,
keyPath: string[],
value: unknown,
): Promise<boolean> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
raw = '';
}
if (raw.trim().length === 0) {
const config: any = {};
let parent: any = config;
for (let i = 0; i < keyPath.length; i++) {
if (i === keyPath.length - 1) {
parent[keyPath[i]] = value;
} else {
parent[keyPath[i]] = {};
parent = parent[keyPath[i]];
}
}
await writeJsonFile(filePath, config);
return true;
}
const tree = parseTree(raw);
if (tree && tree.type === 'object' && (!(tree as any).errors || (tree as any).errors.length === 0)) {
const edits = modify(raw, keyPath, value, {
formattingOptions: { tabSize: 2, insertSpaces: true },
});
const result = applyEdits(raw, edits);
await fs.writeFile(filePath, result, 'utf-8');
return true;
}
try {
const existing = JSON.parse(raw);
let parent = existing;
for (let i = 0; i < keyPath.length - 1; i++) {
if (!parent[keyPath[i]]) parent[keyPath[i]] = {};
parent = parent[keyPath[i]];
}
parent[keyPath[keyPath.length - 1]] = value;
await writeJsonFile(filePath, existing);
return true;
} catch {
return false;
}
}
/**
* Check if a directory exists
*/
@ -267,12 +343,12 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
const configPath = path.join(opencodeDir, 'opencode.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};
if (!config.mcp) config.mcp = {};
config.mcp.gitnexus = getMcpEntry();
await writeJsonFile(configPath, config);
result.configured.push('OpenCode');
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
result.errors.push('OpenCode: opencode.json is corrupt — skipping to preserve existing content');
}
} catch (err: any) {
result.errors.push(`OpenCode: ${err.message}`);
}

View file

@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') {
callback(null, '', '');
}
});
const execFileSyncMock = vi.fn(() => {
throw new Error('not found');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
}));
describe('setupOpenCode — JSONC preservation', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let platformDescriptor: PropertyDescriptor | undefined;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};
const opencodeDir = () => path.join(tempHome, '.config', 'opencode');
const opencodeJsonPath = () => path.join(opencodeDir(), 'opencode.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-opencode-jsonc-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
await fs.mkdir(opencodeDir(), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
setPlatform('linux');
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('preserves line comments (//)', async () => {
const jsonc = `{
// This comment must survive
"model": "test"
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('This comment must survive');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toBeDefined();
expect(config.model).toBe('test');
});
it('preserves block comments (/* */)', async () => {
const jsonc = `{
/* block comment */
"model": "test"
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('block comment');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toBeDefined();
expect(config.model).toBe('test');
});
it('preserves trailing comments', async () => {
const jsonc = `{
"model": "test", // inline comment
"provider": "anthropic"
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('inline comment');
const config = parseJsonc(raw);
expect(config.model).toBe('test');
expect(config.provider).toBe('anthropic');
expect(config.mcp.gitnexus).toBeDefined();
});
it('handles plain JSON without comments (backwards compatible)', async () => {
const plain = JSON.stringify({ model: 'test', provider: 'openai' }, null, 2);
await fs.writeFile(opencodeJsonPath(), plain, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.model).toBe('test');
expect(config.provider).toBe('openai');
expect(config.mcp.gitnexus).toBeDefined();
});
it('handles missing opencode.json (creates fresh)', async () => {
await fs.rm(opencodeJsonPath(), { force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toBeDefined();
});
it('preserves all existing top-level keys', async () => {
const jsonc = `{
// my config
"model": "claude-sonnet",
"instructions": "Be helpful",
"plugin": ["foo"],
"provider": "anthropic",
"mcp": { "other": { "command": "bar" } }
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('my config');
const config = parseJsonc(raw);
expect(config.model).toBe('claude-sonnet');
expect(config.instructions).toBe('Be helpful');
expect(config.plugin).toEqual(['foo']);
expect(config.provider).toBe('anthropic');
expect(config.mcp.other).toEqual({ command: 'bar' });
expect(config.mcp.gitnexus).toBeDefined();
});
it('updates existing gitnexus MCP entry without losing other keys', async () => {
execFileSyncMock.mockReturnValueOnce('/usr/local/bin/gitnexus\n');
const jsonc = `{
// config comment
"model": "test",
"mcp": {
"other": { "command": "keep" },
"gitnexus": { "command": "old-gitnexus", "args": ["old"] }
}
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('config comment');
const config = parseJsonc(raw);
expect(config.model).toBe('test');
expect(config.mcp.other).toEqual({ command: 'keep' });
expect(config.mcp.gitnexus).toEqual({
type: 'local',
command: ['/usr/local/bin/gitnexus', 'mcp'],
});
});
it('does not wipe corrupt file content', async () => {
const corrupt = '{ "model": "test" this is broken {{{';
await fs.writeFile(opencodeJsonPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('test');
});
it('uses npx fallback format when gitnexus binary is not on PATH', async () => {
execFileSyncMock.mockImplementation(() => {
throw new Error('not found');
});
const jsonc = `{
"model": "test",
"mcp": {}
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toEqual({
type: 'local',
command: ['npx', '-y', 'gitnexus@latest', 'mcp'],
});
});
it('skips when ~/.config/opencode directory does not exist', async () => {
await fs.rm(opencodeDir(), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(opencodeJsonPath())).rejects.toThrow();
});
});