mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
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:
parent
9dc733adf7
commit
e961c5f073
2 changed files with 328 additions and 6 deletions
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
246
gitnexus/test/unit/setup-jsonc.test.ts
Normal file
246
gitnexus/test/unit/setup-jsonc.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue