GitNexus/gitnexus/test/unit/setup.test.ts
Joseph Yared b92c14cdd0
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 / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat: add Factory AI (Droid) integration (#2543)
* feat(setup): add Factory Droid (MCP + skills) to gitnexus setup

Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid`
writes the MCP server to ~/.factory/mcp.json and installs skills to
~/.factory/skills/ from the single canonical skills/ source (no per-editor
copies). uninstall.ts is target-driven, so removal is covered automatically.
Adds unit + round-trip coverage.

* feat(plugin): add gitnexus-factory-plugin for droid plugin install

* docs: add Factory Droid to editor support table and setup docs

* fix(factory-plugin): guard augment hook against fan-out and DB contention

Reuse the Claude adapter's acquireHookSlot and LadybugDB owner probe
(bundled byte-identical, kept in lockstep by a drift test) instead of
running an unguarded augment. Add direct tests for the hook and manifests.

* docs: align Factory row in editor support table

* fix(factory-plugin): honor GITNEXUS_HOOK_CLI_PATH so augment runs on Windows

* docs(hooks): point bundled guard copies at their drift tests

* docs(factory-plugin): note the Execute tokenizer's quoting limit

* docs(readme): clarify the Full tier and group the Factory row

* docs(hooks): trim drift note to a single line

* test(ci): run factory-plugin tests on the windows cross-platform lane

* refactor(hooks): drop the drift-note comments, the tests already enforce it

* fix(factory-plugin): pin CLI version and parse quoted shell patterns

- Pin mcp.json and the hook's npx fallback to gitnexus@<version> from
  the plugin manifest, registered with the release sync script so a
  mutable @latest can never execute on MCP connect or augment fallback
- Port the #2938 shell tokenizer (tokenizeShellWords + parseRgGrepPattern)
  so quoted, backslash-escaped, --regexp=, -eVALUE, and -- patterns survive
- Add the #2938 regression matrix and pin assertions to factory-plugin.test.ts

* docs: add Factory Droid to published npm README

* fix(factory-plugin): wire marketplace so droid installs the Factory plugin

Add .factory-plugin/marketplace.json sourcing ./gitnexus-factory-plugin.
Droid reads it before .claude-plugin/marketplace.json, so
`droid plugin install` now delivers the Factory plugin (Execute matcher,
pinned mcp.json) instead of the translated Claude plugin (Bash matcher,
gitnexus@latest). Register the surface in the version-sync script and
cover the wiring in the factory and sync test suites.

* fix(factory-plugin): use registry lookup for index resolution

Bundle registry-query.cjs so external indexes resolve (#3060); re-pin to 1.6.12.

* fix(factory-plugin): sync Execute parser with Cursor hook

Fixes echo-rg and -f false positives; tighten test env isolation.

* fix(factory-plugin): stop no-match augment from re-running via npx

A PATH `gitnexus` that finds no match exits 0 with empty stderr, which
fell through to a second `npx -y gitnexus@<pin> augment` with its own 8s
timeout (16s worst case vs the 10s hook budget). Fall through to npx only
when the PATH launcher is missing (ENOENT); any launched PATH binary,
including a timeout or non-zero exit, now ends the augment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): filter augment stderr to the [GitNexus] block

runAugment returned raw child stderr, so npm/Node/LadybugDB warnings leaked
into additionalContext and noise-only stderr counted as success. Port the
Claude adapter's extractAugmentContext (verbatim, with isDebugEnabled) and
apply it on every launch tier before the success decision. Adds a drift test
against the Claude copy and PATH-tier noise/noise-only behavior tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): quote DROID_PLUGIN_ROOT in hook command

An unquoted plugin root containing spaces (e.g. a Windows user profile
path) split into multiple argv words, so the PostToolUse hook silently
never ran. Quote it like the Claude plugin does, and pin the exact
quoted command in the hooks.json wiring test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(release): stage Factory plugin manifests in the release commit

The rc release job stages only the original four manifest surfaces in the
detached release commit, so the v<version> tag tree carried the Factory
plugin.json, mcp.json and marketplace.json at the previous version while
--check (working tree) passed. Stage them too, and guard the git add block
against the synced surfaces in sync-plugin-manifests.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(factory-plugin): simplify hook gates, spawn tiers and tests

- main(): resolve the repo only after the tool-name and pattern gates,
  matching the Claude/Cursor hook order (skips fs/git work on no-op calls).
- runAugment(): share one spawnAugment helper between the
  GITNEXUS_HOOK_CLI_PATH and npx tiers; PATH tier ENOENT logic unchanged.
- factory-plugin test: pre-filter comment lines instead of `continue`.
- sync-plugin-manifests test: hoist EXECUTABLE_MCP_FILES and derive
  TOTAL_SURFACES from its length.
- fnSource(): throw when the function or its closing brace is not found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): list Factory Droid in localized setup help

`localizeCliHelp` overwrites the `setup` command description with the
`help.command.setup.description` i18n key, so the literal edited in
index.ts never reached `gitnexus setup --help`. Add Factory Droid to the
en and zh-CN keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#2543)

- factory hook: run every augment tier under the bundled Unix timeout
  guard (npx tier group-kills), keeping exactly-one-tier fall-through
- hook-db-lock-probe: trim GITNEXUS_HOOK_{LSOF,PS}_PATH once so a padded
  override is used, not silently replaced (all 3 copies)
- hook-lock: evict a stale slot via rename-to-tombstone + identity check,
  so a concurrently recreated fresh lock is never deleted (all 4 copies)
- registry-query: a set-but-invalid storage override resolves no repo
  instead of falling back to the registry storagePath (all 4 copies)
- publish.yml: stage the ten skill mcp.json manifests in the rc release
  commit; the staging test now requires every synced surface

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 2 (#2543)

- hook-lock: replace rename-to-tombstone eviction with an O_EXCL per-slot
  `.evicting` marker plus an identity re-check before unlink, so a live
  lock is never moved, and a crashed evictor leaves only a self-expiring
  marker (all 4 copies)
- hook-db-lock-probe: clamp GITNEXUS_HOOK_PROC_CMDLINE_MAX to a named
  256 KiB ceiling and require an integer, so an oversized override can
  no longer fail the buffer allocation and miss a live owner (all 3 copies)
- registry-query: treat an empty GITNEXUS_STORAGE_PATH/ROOT as set but
  invalid, matching the CLI's `!== undefined` rule (all 4 copies); the
  factory test env now deletes those keys instead of blanking them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 3 (#2543)

- hook-db-lock-probe: a capped /proc cmdline read stops early only once
  both the GitNexus token and the mcp/serve mode are present (or at EOF,
  the ceiling, or the budget), so a mode word such as `--require mcp`
  before the GitNexus path no longer hides a live owner (all 3 copies)
- registry-query: correct the override comment; a filesystem root is
  invalid only for GITNEXUS_STORAGE_PATH, not GITNEXUS_STORAGE_ROOT
  (all 4 copies, comment only)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 4 (#2543)

- hook-db-lock-probe: an fd-directory read error other than ENOENT or
  ENOTDIR on an identified server candidate now fails closed ('timeout')
  instead of reporting not-owned (EMFILE/ENFILE/ENOMEM/EINTR)
- hook-db-lock-probe: resolve GITNEXUS_HOOK_TIMEOUT_PATH to an absolute
  path before validating and caching it, so callers that spawn with a
  request cwd can still execute the guard
- hook-db-lock-probe: document the chunked cmdline read's actual stop
  conditions (all 3 copies)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Harden hook-lock eviction marker lifecycle (#2543)

Per the chosen option (B) for the stale-slot eviction race:
- `.evicting` markers carry a per-call owner token (pid + random hex)
- an evictor re-reads its token immediately before the slot identity
  check and unlink; a stalled evictor whose marker was broken backs off
- `finally` removes the marker only while it still holds our token
- an orphaned marker is broken only if, re-checked just before unlink,
  its bigint identity and token are unchanged from when judged stale
- doc comment states the two remaining two-syscall windows (slot
  lstat->unlink, marker token->unlink); POSIX has no conditional
  unlink, and the worst case is one extra concurrent augment

All four byte-identical hook-lock copies updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix CodeQL file-system race in hook-lock orphan-marker check (#2543)

breakOrphanedMarker stat'd the marker by path and then read it by path,
which CodeQL flags (js/file-system-race): the file could be replaced
between the two calls. Take the stat and the token from one open
descriptor (readMarkerSnapshot, O_NOFOLLOW where available) for both the
"judged stale" snapshot and the pre-unlink re-check. All four hook-lock
copies updated.

The replaced-marker test injected its swap via a readFileSync(path) spy,
which no longer fires; it now swaps the marker just before its second
open, counting opens of the marker path only (a per-path counter fired
early on slot-0 and let a mutant pass).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Unregister hook-lock exit listener on release (#2543)

Each acquireHookSlot registered `release` as a process 'exit' listener
that was never removed, so a long-lived process acquiring and releasing
slots repeatedly would accumulate listeners (MaxListenersExceededWarning)
and retain every closure. release() now removes itself. All four
hook-lock copies updated; a test asserts 12 acquire/release cycles leave
the 'exit' listener count unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:12:40 +01:00

1088 lines
41 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 { packageVersion } from '../../src/core/package-version.js';
const PKG_VERSION = packageVersion();
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 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();
await expect(fs.access(path.join(destHooksDir, 'registry-query.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(5);
expect(result.errors.some((e) => e.includes('resolve-analyze-cmd.cjs'))).toBe(true);
expect(result.errors.some((e) => e.includes('registry-query.cjs'))).toBe(true);
expect(result.errors.every((e) => e.startsWith('Claude Code hooks:'))).toBe(true);
// Only the hard-required .cjs helpers gate registration; win-rm is best-effort.
expect([...failedRequired].sort()).toEqual([
'hook-db-lock-probe.cjs',
'hook-lock.cjs',
'registry-query.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 all 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',
'registry-query.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('setupDroid (Factory)', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const configPath = () => path.join(tempHome, '.factory', '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-droid-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.factory so other editors skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.factory'), { recursive: true });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
// Assigning undefined would set the string "undefined"; delete instead.
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes the MCP entry to ~/.factory/mcp.json under mcpServers', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('preserves existing servers in ~/.factory/mcp.json', async () => {
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();
});
it('skips when ~/.factory directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.factory'), { 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 ~/.factory/mcp.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"',
);
});
});