GitNexus/gitnexus/test/unit/setup.test.ts
Gergő Magyar 187c162fd8
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
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 / 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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat: full Codex support — hooks, plugin marketplace, and setup (#2328, supersedes #1131) (#2369)
* feat(setup): install Codex PreToolUse/PostToolUse hooks (#2328)

Codex CLI supports lifecycle hooks with Claude Code's exact
{hooks: {Event: [...]}} JSON schema, stdin payload, and
hookSpecificOutput response contract, registered in a dedicated
~/.codex/hooks.json (https://developers.openai.com/codex/hooks).

Parameterize installClaudeCodeHooks into installClaudeSchemaHooks
(claude | codex): both runtimes share the installer, the bundled
gitnexus-hook.cjs adapter, and its helpers. A codex HookTarget in
editor-targets.ts makes uninstall and the setup-uninstall round-trip
tripwire cover the new surface with no uninstall.ts changes.

SessionStart is deliberately not registered: Codex reads AGENTS.md
natively, which already carries the GitNexus context block.

Closes #2328. Closes #244 (Codex setup support is now complete:
MCP + skills + hooks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugin): make the GitNexus plugin installable from Codex (#1131)

Codex's plugin system (https://developers.openai.com/codex/plugins/build)
reads a .codex-plugin/plugin.json manifest and a repo-root
.agents/plugins/marketplace.json registry. The existing
gitnexus-claude-plugin/ is already Codex-compatible as-is — Codex sets
CLAUDE_PLUGIN_ROOT for hook-command compatibility, loads the same
SKILL.md skills, hooks/hooks.json, and .mcp.json — so a second manifest
in the same folder replaces PR #1131's duplicated plugin tree with zero
copied skills or hooks. The .gitignore .agents/ scratch rule narrows to
re-include only the registry file.

Install: codex plugin marketplace add abhigyanpatwari/GitNexus

Supersedes #1131.

Co-authored-by: jublin <1799126+jublin@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document Codex full support (MCP + skills + hooks + plugin)

Promote Codex to Full in both editor tables, document the
~/.codex/hooks.json hook install, and add the Codex plugin
marketplace install path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(release): extend the version-lockstep guard to the Codex manifests

The always-on drift guard asserted only the Claude plugin manifests
against gitnexus/package.json, so a release could ship stale versions in
.codex-plugin/plugin.json and .agents/plugins/marketplace.json without
CI noticing. Mirror the Claude lockstep test for the two Codex files and
extend the CONTRIBUTING §Releases lockstep list to match.

Verified guard semantics: a deliberate local version mutation of the
Codex marketplace entry turns the new test red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin): quote the hook command path for space-containing plugin roots

Both plugin hook commands ran `node ${CLAUDE_PLUGIN_ROOT}/hooks/...`
unquoted, which breaks whenever the substituted plugin root contains a
space — the common case on Windows user profiles. Both Claude Code and
Codex substitute the placeholder before shell execution, and Claude
Code's plugin docs mandate the double-quoted form in shell-form hooks.

No commandWindows entry: Codex source (codex-rs hooks engine) falls back
to `command` on Windows with identical placeholder substitution, so an
identical-content override would be pure duplication.

Verified: space-in-root smoke test (old form exits 1 MODULE_NOT_FOUND,
quoted form exits 0), `claude plugin validate` passes, and a local
`codex plugin marketplace add` parses the marketplace + plugin cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(setup): pin fail-closed behavior for unreadable/corrupt Codex hooks.json

The non-ENOENT suite covered Claude settings.json (EACCES) and Codex
config.toml (EACCES) but not the new ~/.codex/hooks.json surface, and the
mergeHooksJsonc "is corrupt" branch had zero coverage for either editor.
A future refactor dropping the isEnoent rethrow or the parse gate could
silently rewrite a user's hooks.json gitnexus-only with no CI tripwire.

Two regression tests: EACCES leaves hooks.json byte-identical and reports
"Codex hooks: EACCES"; corrupt content is preserved and reported via
"Codex hooks: hooks.json is corrupt".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): add the Codex plugin-marketplace install path to the npm README

The root README documents the one-step plugin route but the package
README (what npmjs.com renders) only showed the setup-CLI path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(setup): rename claudeHook to hookCfg in installClaudeSchemaHooks

The local held a codex HookTarget on the codex branch since the installer
was parameterized, so the claude-specific name misled. Pure local rename,
no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): document Codex SessionStart exclusion, /hooks trust gate, and install-route choice

Three behaviors were only recorded in code comments and the PR body:
SessionStart is deliberately not registered (Codex reads AGENTS.md
natively), setup-installed hooks need one-time /hooks approval in Codex,
and the setup CLI and plugin are alternative install routes whose hooks
load alongside each other if both are used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): share one logLines helper across setup.test.ts describes

The corrupt-hooks.json test inlined the console.log-flattening
expression that the non-ENOENT describe already defined locally. Hoist a
single file-scope logLines so the two stay in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:32:17 +01:00

1007 lines
38 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 { createRequire } from 'module';
// Match what setup.ts emits — read the version from the same package.json
// so the test never goes stale on a release bump.
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
/** Flatten the spied console.log calls into one searchable string. */
const logLines = () =>
vi
.mocked(console.log)
.mock.calls.map((call) => call.join(' '))
.join('\n');
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') {
callback(null, '', '');
}
});
// By default, execFileSync throws (simulating `which gitnexus` not found)
// so getMcpEntry() falls back to the npx path.
const execFileSyncMock = vi.fn(() => {
throw new Error('not found');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
}));
describe('setupClaudeCode', () => {
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,
});
};
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-claude-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.claude — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
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('writes win32 MCP entry with cmd wrapper', async () => {
setPlatform('win32');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
it('writes non-win32 MCP entry with npx directly', async () => {
setPlatform('darwin');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', MCP_PINNED_REF, 'mcp'],
});
});
it('skips when ~/.claude directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.claude'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
});
it('preserves existing keys in ~/.claude.json', async () => {
setPlatform('linux');
await fs.writeFile(
path.join(tempHome, '.claude.json'),
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('handles missing ~/.claude.json (creates fresh)', async () => {
setPlatform('linux');
// Ensure no pre-existing file
await fs.rm(path.join(tempHome, '.claude.json'), { force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('handles corrupt JSON gracefully', async () => {
setPlatform('linux');
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(path.join(tempHome, '.claude.json'), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// mergeJsoncFile leaves corrupt files untouched (safer than overwriting)
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
expect(raw).toBe(corrupt);
});
it('uses global binary path when gitnexus is on PATH', async () => {
setPlatform('darwin');
execFileSyncMock.mockReturnValueOnce('/usr/local/bin/gitnexus\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: '/usr/local/bin/gitnexus',
args: ['mcp'],
});
});
it('falls back to npx when gitnexus is not on PATH', async () => {
setPlatform('darwin');
execFileSyncMock.mockImplementationOnce(() => {
throw new Error('not found');
});
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', MCP_PINNED_REF, 'mcp'],
});
});
it('picks .cmd wrapper from Windows where output (multiple lines)', async () => {
setPlatform('win32');
// `where gitnexus` on Windows returns the POSIX script first, then .cmd
execFileSyncMock.mockReturnValueOnce(
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\n',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
args: ['mcp'],
});
});
it('handles CRLF line endings from Windows where output', async () => {
setPlatform('win32');
// Windows `where` produces CRLF line endings
execFileSyncMock.mockReturnValueOnce(
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\r\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\r\n',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
args: ['mcp'],
});
});
it('picks .bat wrapper when .cmd is not present', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce(
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat\n',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat',
args: ['mcp'],
});
});
it('handles uppercase .CMD extension (case-insensitive match)', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce(
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD\n',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD',
args: ['mcp'],
});
});
it('copies shared hook helpers (incl. resolve-analyze-cmd.cjs) to ~/.claude/hooks/gitnexus/', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await expect(fs.access(path.join(destHooksDir, 'hook-lock.cjs'))).resolves.toBeUndefined();
await expect(
fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')),
).resolves.toBeUndefined();
// The Claude adapter top-level require()s this; without it the installed
// hook would crash with MODULE_NOT_FOUND (the antigravity-side bug class).
await expect(
fs.access(path.join(destHooksDir, 'resolve-analyze-cmd.cjs')),
).resolves.toBeUndefined();
});
it('records errors and returns the failed REQUIRED helpers when copies fail', async () => {
const { copyHookHelpers } = await import('../../src/cli/setup.js');
const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-copy-helpers-'));
const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] };
try {
// A non-existent source dir makes every helper copy fail.
const failedRequired = await copyHookHelpers(
path.join(destDir, 'nope'),
destDir,
'Claude Code hooks',
result,
);
expect(result.errors.length).toBe(4);
expect(result.errors.some((e) => e.includes('resolve-analyze-cmd.cjs'))).toBe(true);
expect(result.errors.every((e) => e.startsWith('Claude Code hooks:'))).toBe(true);
// Only the hard-required .cjs trio gates registration; win-rm is best-effort.
expect([...failedRequired].sort()).toEqual([
'hook-db-lock-probe.cjs',
'hook-lock.cjs',
'resolve-analyze-cmd.cjs',
]);
expect(failedRequired).not.toContain('win-rm-list-json.ps1');
} finally {
await fs.rm(destDir, { recursive: true, force: true });
}
});
it('treats win-rm-list-json.ps1 as best-effort (no required failure when only it is missing)', async () => {
const { copyHookHelpers } = await import('../../src/cli/setup.js');
const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-src-'));
const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-dest-'));
const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] };
try {
// Provide the three hard-required .cjs helpers; omit win-rm-list-json.ps1.
for (const h of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs']) {
await fs.writeFile(path.join(srcDir, h), '// stub\n', 'utf-8');
}
const failedRequired = await copyHookHelpers(srcDir, destDir, 'Claude Code hooks', result);
expect(failedRequired).toEqual([]);
// The best-effort helper still records a (non-gating) error.
expect(result.errors.some((e) => e.includes('win-rm-list-json.ps1'))).toBe(true);
} finally {
await fs.rm(srcDir, { recursive: true, force: true });
await fs.rm(destDir, { recursive: true, force: true });
}
});
it('does not register the Claude hook when a required helper fails to copy (fail closed)', async () => {
setPlatform('linux');
const realCopyFile = fs.copyFile.bind(fs);
vi.spyOn(fs, 'copyFile').mockImplementation(((src: any, dest: any, ...rest: any[]) => {
if (String(src).endsWith('resolve-analyze-cmd.cjs')) {
return Promise.reject(new Error('simulated copy failure'));
}
return realCopyFile(src, dest, ...rest);
}) as typeof fs.copyFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// Registration must have been skipped — settings.json must not reference the hook.
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
let registered = false;
try {
registered = (await fs.readFile(settingsPath, 'utf-8')).includes('gitnexus-hook.cjs');
} catch {
registered = false;
}
expect(registered).toBe(false);
});
it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => {
setPlatform('win32');
// Edge case: where returns only a non-spawnable shim (no .cmd wrapper)
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(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
it('falls back to npx on Windows when where returns only a .ps1 path', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
// The hook `command` string is shell-evaluated by the editor. On POSIX the
// installed path lives under $HOME, which can legitimately contain spaces and
// (adversarially) shell metacharacters; the command must neutralize them.
it('single-quotes the POSIX hook command so the path cannot word-split or expand', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const settings = JSON.parse(
await fs.readFile(path.join(tempHome, '.claude', 'settings.json'), 'utf-8'),
);
const cmd: string = settings.hooks.PreToolUse[0].hooks[0].command;
// setup.ts forward-slash-normalizes the hook path (`.replace(/\\/g, '/')`)
// before quoting, so normalize the expected path the same way — otherwise
// path.join emits backslashes on the Windows runner and this mismatches.
const hookPath = path
.join(tempHome, '.claude', 'hooks', 'gitnexus', 'gitnexus-hook.cjs')
.replace(/\\/g, '/');
// Single-quoted, not double-quoted, and the path is the literal inside quotes.
expect(cmd).toBe(`node '${hookPath}'`);
expect(cmd.startsWith("node '")).toBe(true);
expect(cmd).not.toMatch(/^node "/);
});
});
describe('setupCodeBuddy', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const recommendedPath = () => path.join(tempHome, '.codebuddy', '.mcp.json');
const deprecatedPath = () => path.join(tempHome, '.codebuddy', 'mcp.json');
const legacyPath = () => path.join(tempHome, '.codebuddy.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codebuddy-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.codebuddy — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('creates the recommended ~/.codebuddy/.mcp.json when no config exists', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(recommendedPath(), 'utf-8'));
// Entry shape (binary vs npx vs cmd-wrapper) is covered by the Claude
// suite; here we only care that it landed in the recommended file.
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(deprecatedPath())).rejects.toThrow();
});
it('writes into an existing deprecated ~/.codebuddy/mcp.json instead of shadowing it', async () => {
// CodeBuddy reads only the FIRST existing file in its priority chain
// (.mcp.json > mcp.json > ~/.codebuddy.json). Creating .mcp.json above a
// populated mcp.json would make the user's other servers disappear.
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('writes into a legacy ~/.codebuddy.json when it is the only config file (dir present)', async () => {
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('prefers the recommended file over deprecated ones when both exist', async () => {
await fs.writeFile(recommendedPath(), JSON.stringify({ mcpServers: {} }), 'utf-8');
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const recommended = JSON.parse(await fs.readFile(recommendedPath(), 'utf-8'));
expect(recommended.mcpServers.gitnexus).toBeDefined();
const deprecated = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(deprecated.mcpServers.gitnexus).toBeUndefined();
});
it('configures via a legacy ~/.codebuddy.json even when ~/.codebuddy/ is absent', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
// MCP-only shape: neither the recommended file nor the directory (and thus
// no skills tree) may be manufactured.
await expect(fs.access(path.join(tempHome, '.codebuddy'))).rejects.toThrow();
});
it('stays "not installed" when the only trace is a 0-byte legacy file (no dir manufactured)', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
await fs.writeFile(legacyPath(), '', 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(legacyPath(), 'utf-8')).toBe('');
await expect(fs.access(path.join(tempHome, '.codebuddy'))).rejects.toThrow();
});
it('skips a 0-byte recommended file so it cannot shadow a populated deprecated one', async () => {
await fs.writeFile(recommendedPath(), '', 'utf-8');
await fs.writeFile(
deprecatedPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const deprecated = JSON.parse(await fs.readFile(deprecatedPath(), 'utf-8'));
expect(deprecated.mcpServers.other).toEqual({ command: 'foo' });
expect(deprecated.mcpServers.gitnexus).toBeDefined();
// The empty recommended file is left exactly as it was.
expect(await fs.readFile(recommendedPath(), 'utf-8')).toBe('');
});
it('skips a directory-shaped candidate and writes the next chain file', async () => {
await fs.mkdir(deprecatedPath(), { recursive: true });
await fs.writeFile(
legacyPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const legacy = JSON.parse(await fs.readFile(legacyPath(), 'utf-8'));
expect(legacy.mcpServers.other).toEqual({ command: 'foo' });
expect(legacy.mcpServers.gitnexus).toBeDefined();
// The directory is untouched and the recommended file was not created
// above the chain (only chain-resolution decided the destination).
expect((await fs.stat(deprecatedPath())).isDirectory()).toBe(true);
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('reports a corrupt deprecated file without creating the recommended file above it', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(deprecatedPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(deprecatedPath(), 'utf-8')).toBe(corrupt);
// Creating .mcp.json above the corrupt file would shadow it once fixed.
await expect(fs.access(recommendedPath())).rejects.toThrow();
});
it('skips when ~/.codebuddy directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(recommendedPath())).rejects.toThrow();
await expect(fs.access(legacyPath())).rejects.toThrow();
});
it('leaves a corrupt config untouched', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(recommendedPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(recommendedPath(), 'utf-8')).toBe(corrupt);
});
});
describe('setupQoder', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const configPath = () => path.join(tempHome, '.qoder.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-qoder-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.qoder — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.qoder'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes the MCP entry to ~/.qoder.json', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
// Entry shape is covered by the Claude suite; assert placement only.
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('preserves existing keys in ~/.qoder.json', async () => {
await fs.writeFile(
configPath(),
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('configures via ~/.qoder.json even when ~/.qoder/ is absent', async () => {
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
await fs.writeFile(
configPath(),
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
await expect(fs.access(path.join(tempHome, '.qoder'))).rejects.toThrow();
});
it('skips when ~/.qoder directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(configPath())).rejects.toThrow();
});
it('leaves a corrupt ~/.qoder.json untouched', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(configPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(configPath(), 'utf-8')).toBe(corrupt);
});
});
describe('Codex hooks (installClaudeSchemaHooks)', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json');
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.codex — no other editor directories so their
// setup functions skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('registers PreToolUse + PostToolUse in ~/.codex/hooks.json and installs the adapter', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks;
expect(hooks).toMatchObject({
PreToolUse: [{ matcher: 'Grep|Glob|Bash' }],
PostToolUse: [{ matcher: 'Bash' }],
});
for (const event of ['PreToolUse', 'PostToolUse']) {
expect(hooks[event][0].hooks[0].command).toContain('gitnexus-hook');
}
await expect(
fs.access(path.join(tempHome, '.codex', 'hooks', 'gitnexus', 'gitnexus-hook.cjs')),
).resolves.toBeUndefined();
});
it('is idempotent — a second setup run adds no duplicate entries', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await setupCommand();
const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks;
expect(hooks.PreToolUse).toHaveLength(1);
expect(hooks.PostToolUse).toHaveLength(1);
});
it('preserves a user-owned hook already present in hooks.json', async () => {
await fs.writeFile(
hooksJsonPath(),
JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] }],
},
}),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks;
const commands: string[] = hooks.PreToolUse.flatMap((e: { hooks: { command: string }[] }) =>
e.hooks.map((h) => h.command),
);
expect(commands).toContain('my-own-hook');
expect(commands.some((c: string) => c.includes('gitnexus-hook'))).toBe(true);
});
it('does not write hooks.json when ~/.codex is absent', async () => {
await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(hooksJsonPath())).rejects.toThrow();
});
it('leaves a corrupt hooks.json untouched and reports it (fail closed)', async () => {
const corrupt = '{ this is not valid json !!!';
await fs.writeFile(hooksJsonPath(), corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(await fs.readFile(hooksJsonPath(), 'utf-8')).toBe(corrupt);
expect(logLines()).toContain('Codex hooks: hooks.json is corrupt');
});
});
describe('setup — non-ENOENT read/stat failures are surfaced, not masked', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const errnoError = (code: string) =>
Object.assign(new Error(`${code}: simulated failure`), { code });
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-enoent-narrow-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('does not clobber an unreadable MCP config and still configures other editors', async () => {
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
await fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true });
const codebuddyMcp = path.join(tempHome, '.codebuddy', '.mcp.json');
const raw = JSON.stringify({ mcpServers: { mine: { command: 'mine' } } });
await fs.writeFile(codebuddyMcp, raw, 'utf-8');
// Readable-by-stat but unreadable-by-read (the reproduced clobber shape).
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === codebuddyMcp) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
// The populated config survives byte-identical instead of becoming
// a gitnexus-only document reported as success.
expect(await fs.readFile(codebuddyMcp, 'utf-8')).toBe(raw);
expect(logLines()).toContain('CodeBuddy: EACCES');
const cursorCfg = JSON.parse(
await fs.readFile(path.join(tempHome, '.cursor', 'mcp.json'), 'utf-8'),
);
expect(cursorCfg.mcpServers.gitnexus).toBeDefined();
});
it('surfaces a chain-candidate stat failure instead of writing a lower-priority file', async () => {
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
const legacy = path.join(tempHome, '.codebuddy.json');
const raw = JSON.stringify({ mcpServers: { mine: { command: 'mine' } } });
await fs.writeFile(legacy, raw, 'utf-8');
const recommended = path.join(tempHome, '.codebuddy', '.mcp.json');
const realStat = fs.stat;
vi.spyOn(fs, 'stat').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === recommended) return Promise.reject(errnoError('EACCES'));
return (realStat as any)(file, ...rest);
}) as typeof fs.stat);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.stat).mockRestore();
expect(logLines()).toContain('CodeBuddy: EACCES');
// Neither silently routed to the legacy file nor created the recommended one.
expect(await fs.readFile(legacy, 'utf-8')).toBe(raw);
await expect(fs.access(recommended)).rejects.toThrow();
});
it('does not rewrite an unreadable settings.json as hooks-only (fail closed)', async () => {
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
const raw = JSON.stringify({ mySetting: true, hooks: { PreToolUse: [] } });
await fs.writeFile(settingsPath, raw, 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === settingsPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
// The user's settings survive; the hook installer reports instead of
// replacing the whole file with a hooks-only document.
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Claude Code hooks: EACCES');
});
it('reports a Codex error instead of rewriting an unreadable config.toml', async () => {
await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true });
const configPath = path.join(tempHome, '.codex', 'config.toml');
const raw = '[mcp_servers.other]\ncommand = "other"\n';
await fs.writeFile(configPath, raw, 'utf-8');
// Force the TOML fallback (default execFile mock succeeds → CLI path).
execFileMock.mockImplementationOnce((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(new Error('codex not found'), '', '');
});
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === configPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Codex: EACCES');
});
it('does not rewrite an unreadable ~/.codex/hooks.json as hooks-only (fail closed)', async () => {
await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true });
const hooksPath = path.join(tempHome, '.codex', 'hooks.json');
const raw = JSON.stringify({
hooks: { PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'mine' }] }] },
});
await fs.writeFile(hooksPath, raw, 'utf-8');
const realReadFile = fs.readFile;
vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => {
if (String(file) === hooksPath) return Promise.reject(errnoError('EACCES'));
return (realReadFile as any)(file, ...rest);
}) as typeof fs.readFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
vi.mocked(fs.readFile).mockRestore();
// The user's hooks survive; the installer reports instead of replacing
// the whole file with a gitnexus-only document.
expect(await fs.readFile(hooksPath, 'utf-8')).toBe(raw);
expect(logLines()).toContain('Codex hooks: EACCES');
});
});
describe('formatHookCommand (hook command escaping, #1945)', () => {
let mod: typeof import('../../src/cli/setup.js');
beforeEach(async () => {
mod = await import('../../src/cli/setup.js');
});
it('single-quotes an ordinary POSIX path', () => {
expect(mod.formatHookCommand('/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs', false)).toBe(
"node '/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs'",
);
});
it('neutralizes spaces in a POSIX path (no word-splitting)', () => {
expect(mod.formatHookCommand('/home/a b/.claude/gitnexus-hook.cjs', false)).toBe(
"node '/home/a b/.claude/gitnexus-hook.cjs'",
);
});
it('neutralizes shell metacharacters in a POSIX path ($, backtick, ;)', () => {
// Single-quoting means none of these can expand or run as a command.
const evil = '/home/u$(id)/`whoami`/a;b/.claude/gitnexus-hook.cjs';
expect(mod.formatHookCommand(evil, false)).toBe(`node '${evil}'`);
});
it("escapes a single quote in a POSIX path via the '\\'' idiom", () => {
expect(mod.formatHookCommand("/home/o'brien/.claude/gitnexus-hook.cjs", false)).toBe(
"node '/home/o'\\''brien/.claude/gitnexus-hook.cjs'",
);
});
it('keeps the double-quoted form on Windows (metacharacters are illegal in filenames)', () => {
expect(mod.formatHookCommand('C:/Users/dev/.claude/gitnexus-hook.cjs', true)).toBe(
'node "C:/Users/dev/.claude/gitnexus-hook.cjs"',
);
});
});