From e961c5f073cccf0d9a67082f195cae2dcfe06a39 Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Tue, 21 Apr 2026 01:27:01 +0700 Subject: [PATCH] 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 --- gitnexus/src/cli/setup.ts | 88 ++++++++- gitnexus/test/unit/setup-jsonc.test.ts | 246 +++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 gitnexus/test/unit/setup-jsonc.test.ts diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 8263405a5..1cc8efd5c 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -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 { 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 { + 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 { 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}`); } diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts new file mode 100644 index 000000000..5f2c82a38 --- /dev/null +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -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(); + }); +});