fix: resolve CLI path on Windows after gitnexus setup (#108, #132)

Two bugs caused the PreToolUse hook to fail after `gitnexus setup`
copied it to ~/.claude/hooks/gitnexus/:

1. **Hook: resolveCliPath() only had 2 fallbacks**, both of which fail
   when the script runs from outside the npm package tree. The relative
   path resolves to ~/.claude/dist/cli/index.js (wrong), and
   require.resolve() can't find the package from ~/.claude/hooks/.

   Fix: 6-step fallback chain — injected constant → relative path →
   require.resolve → which/where binary discovery → npm root -g → npx.
   Handles Windows (where/npm.cmd/shell:true) and Unix alike. Splits
   `where` output with /\r?\n/ to avoid trailing CR on Windows.

2. **Setup: String.replace() silently failed** because the match target
   omitted the 2-space indentation present in the source file. The hook
   was copied verbatim without the CLI path injection.

   Fix: Instead of fragile string matching, prepend a
   `const GITNEXUS_CLI_PATH = "..."` constant to the top of the copied
   file. The hook's resolveCliPath() checks this constant first.

Tested on Windows 11 with global npm install. The `where gitnexus`
fallback correctly resolves to <npm_prefix>/node_modules/gitnexus/
dist/cli/index.js.

Closes #108
Closes #132
This commit is contained in:
RumblinTum 2026-04-05 11:56:03 -05:00
parent 0561d24efd
commit 644cf69d17
2 changed files with 66 additions and 17 deletions

View file

@ -103,20 +103,68 @@ function extractPattern(toolName, toolInput) {
/**
* Resolve the gitnexus CLI path.
* 1. Relative path (works when script is inside npm package)
* 2. require.resolve (works when gitnexus is globally installed)
* 3. Fall back to npx (returns empty string)
* 1. Injected constant (set by `gitnexus setup` when copying this file)
* 2. Relative path (works when script is inside npm package)
* 3. require.resolve (works when gitnexus is in Node module path)
* 4. Discover from installed binary via which/where
* 5. npm root -g fallback
* 6. Fall back to npx (returns empty string)
*/
function resolveCliPath() {
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
cliPath = require.resolve('gitnexus/dist/cli/index.js');
} catch {
cliPath = '';
}
// 1. Injected absolute path (populated by `gitnexus setup`)
if (typeof GITNEXUS_CLI_PATH !== 'undefined' && GITNEXUS_CLI_PATH && fs.existsSync(GITNEXUS_CLI_PATH)) {
return GITNEXUS_CLI_PATH;
}
return cliPath;
// 2. Relative path (works when running from inside the npm package tree)
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (fs.existsSync(cliPath)) return cliPath;
// 3. require.resolve
try {
return require.resolve('gitnexus/dist/cli/index.js');
} catch { /* continue */ }
// 4. Discover from installed binary location
const isWin = process.platform === 'win32';
try {
const whichResult = spawnSync(isWin ? 'where' : 'which', ['gitnexus'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const binPath = (whichResult.stdout || '').split(/\r?\n/)[0].trim();
if (binPath) {
const binDir = path.dirname(binPath);
const candidates = [
// npm global on Windows: <prefix>/gitnexus -> <prefix>/node_modules/gitnexus/
path.join(binDir, 'node_modules', 'gitnexus', 'dist', 'cli', 'index.js'),
// npm global on Unix: <prefix>/bin/gitnexus -> <prefix>/lib/node_modules/gitnexus/
path.join(binDir, '..', 'lib', 'node_modules', 'gitnexus', 'dist', 'cli', 'index.js'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
}
} catch { /* continue */ }
// 5. npm root -g fallback (needs shell:true on Windows for .cmd wrapper)
try {
const npmResult = spawnSync(isWin ? 'npm.cmd' : 'npm', ['root', '-g'], {
encoding: 'utf-8',
timeout: 5000,
shell: isWin,
stdio: ['pipe', 'pipe', 'pipe'],
});
const globalRoot = (npmResult.stdout || '').trim();
if (globalRoot) {
const candidate = path.join(globalRoot, 'gitnexus', 'dist', 'cli', 'index.js');
if (fs.existsSync(candidate)) return candidate;
}
} catch { /* continue */ }
// 6. Fall back to npx
return '';
}
/**

View file

@ -169,15 +169,16 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
let content = await fs.readFile(src, 'utf-8');
// Inject resolved CLI path so the copied hook can find the CLI
// even when it's no longer inside the npm package tree
// Inject resolved CLI path as a constant at the top of the hook file.
// Previous approach used String.replace() targeting a specific line, but
// failed silently due to indentation mismatch (2-space indent in source
// vs no indent in the match string). Prepending a constant is robust
// regardless of formatting. See #108, #132.
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
const jsonCli = JSON.stringify(normalizedCli);
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = ${jsonCli};`,
);
content = `const GITNEXUS_CLI_PATH = ${jsonCli};
` + content;
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip