GitNexus/gitnexus/test/unit/setup-jsonc.test.ts
azizur100389 8307e3f01f
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
fix(setup): preserve existing OpenCode config.jsonc (#2694)
2026-07-26 16:23:34 +01:00

715 lines
22 KiB
TypeScript

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';
import { createRequire } from 'module';
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const NPX_REF = `gitnexus@${PKG_VERSION}`;
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');
const opencodeJsoncPath = () => path.join(opencodeDir(), 'opencode.jsonc');
const configJsonPath = () => path.join(opencodeDir(), 'config.json');
const configJsoncPath = () => path.join(opencodeDir(), 'config.jsonc');
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('updates existing opencode.jsonc instead of creating opencode.json', async () => {
await fs.rm(opencodeJsonPath(), { force: true });
const jsonc = `{
// OpenCode user config
"model": "test",
"mcp": { "other": { "command": "keep" } }
}`;
await fs.writeFile(opencodeJsoncPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsoncPath(), 'utf-8');
expect(raw).toContain('OpenCode user config');
await expect(fs.access(opencodeJsonPath())).rejects.toThrow();
const config = parseJsonc(raw);
expect(config.model).toBe('test');
expect(config.mcp.other).toEqual({ command: 'keep' });
expect(config.mcp.gitnexus).toBeDefined();
});
it('updates existing config.json instead of creating opencode.json', async () => {
await fs.rm(opencodeJsonPath(), { force: true });
await fs.writeFile(
configJsonPath(),
JSON.stringify({ model: 'test', mcp: { other: { command: 'keep' } } }, null, 2),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(configJsonPath(), 'utf-8');
await expect(fs.access(opencodeJsonPath())).rejects.toThrow();
const config = parseJsonc(raw);
expect(config.model).toBe('test');
expect(config.mcp.other).toEqual({ command: 'keep' });
expect(config.mcp.gitnexus).toBeDefined();
});
it('creates opencode.json when only ignored config.jsonc exists', async () => {
await fs.rm(opencodeJsonPath(), { force: true });
await fs.writeFile(configJsoncPath(), '{ "model": "ignored" }', 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const ignoredConfig = parseJsonc(await fs.readFile(configJsoncPath(), 'utf-8'));
expect(ignoredConfig.mcp?.gitnexus).toBeUndefined();
const config = parseJsonc(await fs.readFile(opencodeJsonPath(), 'utf-8'));
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).toBe(corrupt);
expect(raw).not.toContain('gitnexus');
});
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', NPX_REF, 'mcp'],
});
});
it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
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: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('uses Windows npx fallback when where returns only a .ps1 path', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n');
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: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('preserves tab indentation in existing file', async () => {
const tabbed = `{\n\t"model": "test"\n}`;
await fs.writeFile(opencodeJsonPath(), tabbed, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
expect(raw).toContain('\t"model"');
expect(raw).toContain('\t"gitnexus"');
});
it('preserves 4-space indentation in existing file', async () => {
const fourSpace = `{
"model": "test"
}`;
await fs.writeFile(opencodeJsonPath(), fourSpace, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const mcpLine = raw.split('\n').find((l) => l.includes('"gitnexus"'));
expect(mcpLine).toMatch(/^ /);
});
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();
});
});
describe('setupCursor — 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 cursorDir = () => path.join(tempHome, '.cursor');
const mcpPath = () => path.join(cursorDir(), 'mcp.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-cursor-jsonc-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
await fs.mkdir(cursorDir(), { 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('creates fresh mcp.json when missing', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('preserves existing mcpServers and comments', async () => {
const jsonc = `{
// my cursor config
"mcpServers": {
"other": { "command": "keep" }
}
}`;
await fs.writeFile(mcpPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
expect(raw).toContain('my cursor config');
const config = parseJsonc(raw);
expect(config.mcpServers.other).toEqual({ command: 'keep' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('does not wipe corrupt file', async () => {
const corrupt = '{ "mcpServers": broken';
await fs.writeFile(mcpPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
expect(raw).toBe(corrupt);
});
it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
});
describe('setupClaudeCode — 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 claudeDir = () => path.join(tempHome, '.claude');
const mcpPath = () => path.join(tempHome, '.claude.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-claude-jsonc-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
await fs.mkdir(claudeDir(), { 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('creates fresh .claude.json when missing', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('preserves existing keys and comments', async () => {
const jsonc = `{
// my claude config
"permissions": ["read"],
"mcpServers": {
"other": { "command": "keep" }
}
}`;
await fs.writeFile(mcpPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
expect(raw).toContain('my claude config');
const config = parseJsonc(raw);
expect(config.permissions).toEqual(['read']);
expect(config.mcpServers.other).toEqual({ command: 'keep' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('does not wipe corrupt file', async () => {
const corrupt = '{ "permissions": broken';
await fs.writeFile(mcpPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
expect(raw).toBe(corrupt);
});
});
describe('installClaudeCodeHooks — 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 claudeDir = () => path.join(tempHome, '.claude');
const settingsPath = () => path.join(claudeDir(), 'settings.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hooks-jsonc-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
await fs.mkdir(claudeDir(), { 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('creates fresh settings.json with hooks when missing', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath(), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.PreToolUse).toBeDefined();
expect(config.hooks.PostToolUse).toBeDefined();
expect(config.hooks.PreToolUse[0].matcher).toBe('Grep|Glob|Bash');
});
it('appends hooks to existing settings preserving comments', async () => {
const jsonc = `{
// my settings
"permissions": ["read"],
"hooks": {
"PreToolUse": [
{ "matcher": "Write", "hooks": [{ "type": "command", "command": "other-hook" }] }
]
}
}`;
await fs.writeFile(settingsPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath(), 'utf-8');
expect(raw).toContain('my settings');
const config = parseJsonc(raw);
expect(config.permissions).toEqual(['read']);
expect(config.hooks.PreToolUse.length).toBe(2);
expect(config.hooks.PreToolUse[0].matcher).toBe('Write');
expect(config.hooks.PreToolUse[1].hooks[0].command).toContain('gitnexus-hook');
expect(config.hooks.PostToolUse).toBeDefined();
});
it('does not add duplicate gitnexus-hook entries', async () => {
const jsonc = JSON.stringify(
{
hooks: {
PreToolUse: [
{
matcher: 'Grep|Glob|Bash',
hooks: [{ type: 'command', command: 'node "gitnexus-hook.cjs"', timeout: 10 }],
},
],
PostToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node "gitnexus-hook.cjs"', timeout: 10 }],
},
],
},
},
null,
2,
);
await fs.writeFile(settingsPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath(), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.PreToolUse.length).toBe(1);
expect(config.hooks.PostToolUse.length).toBe(1);
});
it('does not wipe corrupt file', async () => {
const corrupt = '{ "hooks": broken';
await fs.writeFile(settingsPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath(), 'utf-8');
expect(raw).toBe(corrupt);
});
it('handles idempotency check with JSONC comments in settings', async () => {
const jsonc = `{
// settings comment
"hooks": {
"PreToolUse": [
{ "matcher": "Grep|Glob|Bash", "hooks": [{ "type": "command", "command": "other-hook" }] }
]
}
}`;
await fs.writeFile(settingsPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath(), 'utf-8');
expect(raw).toContain('settings comment');
const config = parseJsonc(raw);
expect(config.hooks.PreToolUse.length).toBe(2);
expect(config.hooks.PostToolUse.length).toBe(1);
});
});