mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
715 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|