GitNexus/gitnexus/test/unit/setup.test.ts
Derek Pearson 89c03b2ebb
fix: skip Claude augment hook when GitNexus server owns DB (#1493)
* fix(claude): skip augment hook when server owns db

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(hooks): cross-platform DB lock probe for MCP owner guard

Extract hook-db-lock-probe.cjs with a single hasGitNexusDbLockedByGitNexusServer
entry point used by both Claude hooks:

- Linux: scan /proc/<pid>/fd via dev+inode (no lsof required), optional lsof
  fallback; GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS caps scan time
- macOS and other Unix: trusted lsof + ps (absolute paths / env overrides)
- Windows: Restart Manager + Win32_Process via win-rm-list-json.ps1 and
  GITNEXUS_HOOK_POWERSHELL_PATH

Update hooks.test.ts source coverage for the probe module.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update gitnexus/hooks/claude/win-rm-list-json.ps1

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Apply suggestion from @github-actions[bot]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(gitnexus): repair package.json JSON after malformed engines edit

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update Node.js engine version requirement to 22.0.0

* Update Node.js engine version to >=22.0.0

* fix(hooks): address ce-code-review findings on PR #1493

P0:
- Replace malformed `RM_UNIQUE_PROCESS` block in
  `gitnexus/hooks/claude/win-rm-list-json.ps1` (duplicate struct decl +
  duplicate `ProcessStartTime` + unbalanced braces) with a single
  well-formed `[StructLayout(LayoutKind.Sequential, Pack = 4)]` struct,
  so PowerShell `Add-Type` actually compiles and the Windows DB-lock
  probe stops fail-open on every machine.
- `gitnexus/src/cli/setup.ts` now copies `hook-db-lock-probe.cjs` and
  `win-rm-list-json.ps1` into the user's `~/.claude/hooks/gitnexus/`
  alongside `hook-lock.cjs`, preventing the `MODULE_NOT_FOUND` thrown
  by `gitnexus-hook.cjs:18`'s top-level require on every fresh install.
  `gitnexus/test/unit/setup.test.ts` extended to assert both new copy
  destinations.
- Four fail-open hook tests (`ENOENT lsof`, `npx parent line`,
  `non-GitNexus ps line`, `ps ENOENT`) now seed `createHookToolDir`
  with a valid `[GitNexus]` stderr line so
  `expect(parseHookOutput).not.toBeNull()` actually holds on CI.

P1:
- Plugin copy of `win-rm-list-json.ps1` gains `Pack = 4` so its CLR
  struct matches the 12-byte native `RM_UNIQUE_PROCESS` layout
  (multi-blocker `RmGetList` no longer reads mangled `dwProcessId`).
- `GITNEXUS_HOOK_CLI_PATH = ''` now falls through to the resolution
  chain in `gitnexus-hook.cjs`, matching the plugin copy and removing
  the twin-file divergence on empty-string envs.
- Lock-warning suppression test seeds `gitnexusMarkerPath` and asserts
  the augment subprocess actually ran, plus `GITNEXUS_DEBUG=1`
  preserves the full discarded prefix.
- MCP-owner skip branch in both hook copies now emits
  `[GitNexus] augment skipped: MCP server owns DB` on stderr, so
  agents can distinguish intentional skip from silent failure.

P2:
- `ps` loop in `hook-db-lock-probe.cjs` fails-closed on `ETIMEDOUT`
  to mirror the `lsof` handling (symmetric subprocess-probe contract).
- `RmStartSession` return value captured in both `.ps1` copies; exits
  early with `[]` on non-zero so subsequent RM API calls don't operate
  on an invalid handle.
- Windows RM-list `.ps1` encoded cache distinguishes uninitialized
  (`undefined`) from load-failed (`null`) with a one-shot
  `GITNEXUS_DEBUG` warning instead of silently caching empty string.
- `createHookToolDir` helper accepts `lsofOutputLines` and
  `psOutputByPid`; the multi-PID test uses them instead of duplicating
  the fake-binary construction inline.
- All five skip-path tests now assert `result.status === 0` and the
  new skip-signal stderr line.
- `AGENTS.md` documents the seven hook configuration env vars
  (`GITNEXUS_HOOK_CLI_PATH`, `_LSOF_PATH`, `_PS_PATH`,
  `_POWERSHELL_PATH`, `_LINUX_PROC_BUDGET_MS`, `_RM_TARGET`,
  `GITNEXUS_DEBUG`).
- `GITNEXUS_DEBUG` path in `gitnexus-hook.cjs`/`.js` writes the full
  discarded stderr prefix instead of a 180-char preview.
- Inline comment in `hook-db-lock-probe.cjs` explains the intentional
  Windows ETIMEDOUT fail-closed semantics.
- Removed the unnecessary `as WriteFileOptions` cast and orphaned
  `import type { WriteFileOptions }` in `hooks.test.ts`.

P3:
- `isGitNexusServerCommand` unexported from
  `hook-db-lock-probe.cjs` (kept as private helper).
- Env-path overrides (`GITNEXUS_HOOK_CLI_PATH`,
  `_POWERSHELL_PATH`, `_LSOF_PATH`, `_PS_PATH`) require
  `fs.existsSync` before being returned, so typos / stale config fall
  through to the standard resolution chain.

Misc:
- `gitnexus/package.json` engines.node back to `>=22.0.0` (matches
  origin/main and the original PR reviewer's earlier request).

Twin-tree parity / CI sync mechanism tracked separately at
abhigyanpatwari/GitNexus#1591.

Test plan: vitest run test/unit/hooks.test.ts → 113 passed,
18 Unix-only skipped; setup.test.ts → 14 passed.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* trigger

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 16:39:30 +01:00

301 lines
9.8 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 NPX_REF = `gitnexus@${PKG_VERSION}`;
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', NPX_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', NPX_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', NPX_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 hook-db-lock-probe.cjs and win-rm-list-json.ps1 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-db-lock-probe.cjs')),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')),
).resolves.toBeUndefined();
});
it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => {
setPlatform('win32');
// Edge case: where returns only the POSIX script (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: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus',
args: ['mcp'],
});
});
});