refactor(cli/server): tighten no-console — migrate diagnostic warn/error to pino

Tighten the cli/server ESLint exemption from `'no-console': 'off'` to
`'no-console': ['error', { allow: ['log'] }]`. `console.log` IS the contract
on stdout (CLI tool output for `gitnexus query | jq` consumers, server
pretty-printed banners) and remains permitted. Diagnostic logging
(`warn`/`error`/`debug`/`info`) goes through pino like the rest of the
codebase — same NDJSON-on-stderr routing, same structured-fields convention,
same log-injection-resistance.

Migrated 88 sites across 13 files (cli + server). Three sites in
`cli/analyze.ts` are intentional UI patterns (the progress-bar swaps
`console.warn`/`console.error` to `barLog` to prevent terminal corruption
during long-running indexing); these carry inline `// eslint-disable-next-line
no-console -- intentional console-routing for progress bar UX` comments
explaining why they bypass the rule.

Test wiring updated:
- `analyze-worker-timeout.test.ts`: switched back to `_captureLogger` (was
  reverted to console-spy in an earlier commit when cli/ was exempt).
  Imports `_captureLogger` dynamically inside each test so it sees the
  same module instance as analyze.js after `vi.resetModules()` rebuilds
  the singleton.
- `web-ui-serving.test.ts`: console-warn assertion swapped to
  `cap.records()` lookup of the new structured log shape (`r.err`).

Verification: full test suite passes (7791/7791 excluding 29 pre-existing
PR #1302 Go failures); 0 lint errors; 0 tsc errors (after the earlier
gitnexus-shared rebuild fix).

Refs: PR #1336.
This commit is contained in:
Gergo Magyar 2026-05-05 14:38:54 +01:00
parent 67d740dd19
commit bb02b53aa2
16 changed files with 131 additions and 94 deletions

View file

@ -59,11 +59,14 @@ export default [
},
},
// CLI/server packages — allow console.log (CLI stdout is contract; HTTP server uses console for request logs)
// CLI/server packages — `console.log` IS the contract (CLI tool data output
// on stdout, e.g. `gitnexus query | jq`; server pretty-printed banners).
// Diagnostic logging (`warn`/`error`/`debug`/`info`) goes through pino like
// the rest of the codebase.
{
files: ['gitnexus/src/cli/**/*.ts', 'gitnexus/src/server/**/*.ts'],
rules: {
'no-console': 'off',
'no-console': ['error', { allow: ['log'] }],
},
},

View file

@ -10,6 +10,7 @@ import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './skill-gen.js';
import { logger } from '../core/logger.js';
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
@ -293,7 +294,7 @@ Use GitNexus tools to accomplish this task.
installedSkills.push(skill.name);
} catch (err) {
// Skip on error, don't fail the whole process
console.warn(`Warning: Could not install skill ${skill.name}:`, err);
logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
}
}

View file

@ -24,6 +24,7 @@ import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
// init, progress bar, console redirection) can monkey-patch it. The
@ -158,7 +159,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (options?.workerTimeout) {
const workerTimeoutSeconds = Number(options.workerTimeout);
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
console.error(' --worker-timeout must be at least 1 second.\n');
logger.error(' --worker-timeout must be at least 1 second.\n');
process.exitCode = 1;
return;
}
@ -175,7 +176,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (value === undefined) return true;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
console.error(` ${optionName} must be a positive integer.\n`);
logger.error(` ${optionName} must be a positive integer.\n`);
process.exitCode = 1;
return false;
}
@ -206,7 +207,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (options?.embeddingDevice) {
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
if (!allowed.has(options.embeddingDevice)) {
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
logger.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
process.exitCode = 1;
return;
}
@ -291,9 +292,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
};
process.on('SIGINT', sigintHandler);
// Route console output through bar.log() to prevent progress bar corruption
// Route console output through bar.log() to prevent progress bar corruption.
// This is a deliberate UI pattern (not a logging concern): analyze runs a
// long-lived progress bar on stdout; any concurrent console.* write would
// overwrite the bar mid-render. We capture originals, swap to barLog for
// the lifetime of the run, and restore on completion/error/SIGINT.
const origLog = console.log.bind(console);
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
const origWarn = console.warn.bind(console);
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
const origError = console.error.bind(console);
let barCurrentValue = 0;
const barLog = (...args: any[]) => {
@ -302,7 +309,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
bar.update(barCurrentValue);
};
console.log = barLog;
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
console.warn = barLog;
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
console.error = barLog;
// Track elapsed time per phase
@ -367,7 +376,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.stop();
console.log(' Already up to date\n');
@ -440,7 +451,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.update(100, { phase: 'Done' });
@ -465,7 +478,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.stop();
@ -474,14 +489,14 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// Registry name-collision from --name (#829) — surface as an
// actionable error rather than a generic stack-trace.
if (err instanceof RegistryNameCollisionError) {
console.error(`\n Registry name collision:\n`);
console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
console.error(` Options:`);
console.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
console.error(
logger.error(`\n Registry name collision:\n`);
logger.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
logger.error(` Options:`);
logger.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
logger.error(
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`,
);
console.error('');
logger.error('');
process.exitCode = 1;
return;
}
@ -521,34 +536,34 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
msg.includes('heap out of memory') ||
msg.includes('JavaScript heap')
) {
console.error(' This error typically occurs on very large repositories.');
console.error(' Suggestions:');
console.error(' 1. Add large vendored/generated directories to .gitnexusignore');
console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
console.error('');
logger.error(' This error typically occurs on very large repositories.');
logger.error(' Suggestions:');
logger.error(' 1. Add large vendored/generated directories to .gitnexusignore');
logger.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
logger.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
logger.error('');
} else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
// Note: the original arborist "Cannot destructure property 'package' of
// 'node.target'" crash happens inside npm *before* gitnexus code runs,
// so it can't be caught here. This branch handles dependency-resolution
// errors that surface at runtime (e.g. dynamic require failures).
console.error(' This looks like an npm dependency resolution issue.');
console.error(' Suggestions:');
console.error(' 1. Clear the npm cache: npm cache clean --force');
console.error(' 2. Update npm: npm install -g npm@latest');
console.error(' 3. Reinstall gitnexus: npm install -g gitnexus@latest');
console.error(' 4. Or try npx directly: npx gitnexus@latest analyze');
console.error('');
logger.error(' This looks like an npm dependency resolution issue.');
logger.error(' Suggestions:');
logger.error(' 1. Clear the npm cache: npm cache clean --force');
logger.error(' 2. Update npm: npm install -g npm@latest');
logger.error(' 3. Reinstall gitnexus: npm install -g gitnexus@latest');
logger.error(' 4. Or try npx directly: npx gitnexus@latest analyze');
logger.error('');
} else if (
msg.includes('MODULE_NOT_FOUND') ||
msg.includes('Cannot find module') ||
msg.includes('ERR_MODULE_NOT_FOUND')
) {
console.error(' A required module could not be loaded. The installation may be corrupt.');
console.error(' Suggestions:');
console.error(' 1. Reinstall: npm install -g gitnexus@latest');
console.error(' 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze');
console.error('');
logger.error(' A required module could not be loaded. The installation may be corrupt.');
logger.error(' Suggestions:');
logger.error(' 1. Reinstall: npm install -g gitnexus@latest');
logger.error(' 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze');
logger.error('');
}
process.exitCode = 1;

View file

@ -6,6 +6,7 @@
*/
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import {
findRepo,
unregisterRepo,
@ -45,7 +46,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Refusing to clean ${entry.name}: ${err.message}`);
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
continue;
}
throw err;
@ -56,7 +57,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
await unregisterRepo(entry.path);
console.log(`Deleted: ${entry.name} (${entry.storagePath})`);
} catch (err) {
console.error(`Failed to delete ${entry.name}:`, err);
logger.error({ err }, `Failed to delete ${entry.name}:`);
}
}
return;
@ -85,6 +86,6 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
await unregisterRepo(repo.repoPath);
console.log(`Deleted: ${repo.storagePath}`);
} catch (err) {
console.error('Failed to delete:', err);
logger.error({ err }, 'Failed to delete:');
}
};

View file

@ -27,6 +27,7 @@
import http from 'http';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
export interface EvalServerOptions {
port?: string;
@ -332,12 +333,12 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
const ok = await backend.init();
if (!ok) {
console.error('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
logger.error('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
const repos = await backend.listRepos();
console.error(
logger.error(
`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map((r) => r.name).join(', ')}`,
);
@ -347,7 +348,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
if (idleTimeoutSec <= 0) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
console.error('GitNexus eval-server: Idle timeout reached, shutting down');
logger.error('GitNexus eval-server: Idle timeout reached, shutting down');
await backend.disconnect();
process.exit(0);
}, idleTimeoutSec * 1000);
@ -419,15 +420,15 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
});
server.listen(port, '127.0.0.1', () => {
console.error(`GitNexus eval-server: listening on http://127.0.0.1:${port}`);
console.error(` POST /tool/query — search execution flows`);
console.error(` POST /tool/context — 360-degree symbol view`);
console.error(` POST /tool/impact — blast radius analysis`);
console.error(` POST /tool/cypher — raw Cypher query`);
console.error(` GET /health — health check`);
console.error(` POST /shutdown — graceful shutdown`);
logger.error(`GitNexus eval-server: listening on http://127.0.0.1:${port}`);
logger.error(` POST /tool/query — search execution flows`);
logger.error(` POST /tool/context — 360-degree symbol view`);
logger.error(` POST /tool/impact — blast radius analysis`);
logger.error(` POST /tool/cypher — raw Cypher query`);
logger.error(` GET /health — health check`);
logger.error(` POST /shutdown — graceful shutdown`);
if (idleTimeoutSec > 0) {
console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
logger.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
}
try {
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
@ -440,7 +441,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
resetIdleTimer();
const shutdown = async () => {
console.error('GitNexus eval-server: shutting down...');
logger.error('GitNexus eval-server: shutting down...');
await backend.disconnect();
server.close();
process.exit(0);

View file

@ -1,6 +1,7 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
@ -51,7 +52,7 @@ export function registerGroupCommands(program: Command): void {
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
if (!(repoPath in config.repos)) {
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
logger.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
process.exitCode = 1;
return;
}
@ -239,7 +240,7 @@ export function registerGroupCommands(program: Command): void {
const raw = await backend.getGroupService().groupImpact(payload);
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
@ -333,7 +334,7 @@ export function registerGroupCommands(program: Command): void {
});
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}

View file

@ -8,18 +8,19 @@
import { startMCPServer } from '../mcp/server.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
export const mcpCommand = async () => {
// Prevent unhandled errors from crashing the MCP server process.
// LadybugDB lock conflicts and transient errors should degrade gracefully.
process.on('uncaughtException', (err) => {
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
logger.error(`GitNexus MCP: uncaught exception — ${err.message}`);
// Process is in an undefined state after uncaughtException — exit after flushing
setTimeout(() => process.exit(1), 100);
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
logger.error(`GitNexus MCP: unhandled rejection — ${msg}`);
});
// Initialize multi-repo backend from registry.
@ -30,11 +31,11 @@ export const mcpCommand = async () => {
const repos = await backend.listRepos();
if (repos.length === 0) {
console.error(
logger.error(
'GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.',
);
} else {
console.error(
logger.error(
`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map((r) => r.name).join(', ')}`,
);
}

View file

@ -27,6 +27,7 @@
*/
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import {
readRegistry,
resolveRegistryEntry,
@ -51,14 +52,14 @@ export const removeCommand = async (target: string, options?: { force?: boolean
// Idempotent: missing target is a no-op warning, not an error.
// The `availableNames` hint comes from the error itself so users
// can see what they might have meant.
console.warn(`Nothing to remove: ${err.message}`);
logger.warn(`Nothing to remove: ${err.message}`);
return;
}
if (err instanceof RegistryAmbiguousTargetError) {
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
// refuse to guess which one the user meant — surface the full list
// and exit non-zero so scripts don't silently pick the wrong repo.
console.error(`Error: ${err.message}`);
logger.error(`Error: ${err.message}`);
process.exit(1);
}
throw err;
@ -86,7 +87,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Error: ${err.message}`);
logger.error(`Error: ${err.message}`);
process.exit(1);
}
throw err;
@ -104,7 +105,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
} catch (err) {
console.error(`Failed to remove ${entry.name}:`, err);
logger.error({ err }, `Failed to remove ${entry.name}:`);
process.exit(1);
}
};

View file

@ -1,14 +1,15 @@
import { createServer } from '../server/api.js';
import { logger } from '../core/logger.js';
// Catch anything that would cause a silent exit
process.on('uncaughtException', (err) => {
console.error('\n[gitnexus serve] Uncaught exception:', err.message);
if (process.env.DEBUG) console.error(err.stack);
logger.error({ err: err.message }, '\n[gitnexus serve] Uncaught exception:');
if (process.env.DEBUG) logger.error(err.stack);
process.exit(1);
});
process.on('unhandledRejection', (reason: any) => {
console.error('\n[gitnexus serve] Unhandled rejection:', reason?.message || reason);
if (process.env.DEBUG) console.error(reason?.stack);
logger.error({ err: reason?.message || reason }, '\n[gitnexus serve] Unhandled rejection:');
if (process.env.DEBUG) logger.error(reason?.stack);
process.exit(1);
});
@ -22,15 +23,15 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
try {
await createServer(port, host);
} catch (err: any) {
console.error(`\nFailed to start GitNexus server:\n`);
console.error(` ${err.message || err}\n`);
logger.error(`\nFailed to start GitNexus server:\n`);
logger.error(` ${err.message || err}\n`);
if (err.code === 'EADDRINUSE') {
console.error(` Port ${port} is already in use. Either:`);
console.error(` 1. Stop the other process using port ${port}`);
console.error(` 2. Use a different port: gitnexus serve --port 4748\n`);
logger.error(` Port ${port} is already in use. Either:`);
logger.error(` 1. Stop the other process using port ${port}`);
logger.error(` 2. Use a different port: gitnexus serve --port 4748\n`);
}
if (err.stack && process.env.DEBUG) {
console.error(err.stack);
logger.error(err.stack);
}
process.exit(1);
}

View file

@ -17,6 +17,7 @@
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
let _backend: LocalBackend | null = null;
@ -25,7 +26,7 @@ async function getBackend(): Promise<LocalBackend> {
_backend = new LocalBackend();
const ok = await _backend.init();
if (!ok) {
console.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
logger.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
return _backend;
@ -67,7 +68,7 @@ export async function queryCommand(
},
): Promise<void> {
if (!queryText?.trim()) {
console.error('Usage: gitnexus query <search_query>');
logger.error('Usage: gitnexus query <search_query>');
process.exit(1);
}
@ -93,7 +94,7 @@ export async function contextCommand(
},
): Promise<void> {
if (!name?.trim() && !options?.uid) {
console.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
logger.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
process.exit(1);
}
@ -118,7 +119,7 @@ export async function impactCommand(
},
): Promise<void> {
if (!target?.trim()) {
console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
logger.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
process.exit(1);
}
@ -153,7 +154,7 @@ export async function cypherCommand(
},
): Promise<void> {
if (!query?.trim()) {
console.error('Usage: gitnexus cypher <cypher_query>');
logger.error('Usage: gitnexus cypher <cypher_query>');
process.exit(1);
}

View file

@ -19,6 +19,7 @@ import {
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
import { logger } from '../core/logger.js';
export interface WikiCommandOptions {
force?: boolean;
@ -583,7 +584,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
} else {
console.log(`\n Error: ${err.message}\n`);
if (process.env.GITNEXUS_VERBOSE) {
console.error(err);
logger.error(err);
}
}
process.exitCode = 1;

View file

@ -26,8 +26,6 @@ import { isWriteQuery } from '../core/lbug/pool-adapter.js';
import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared';
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at server startup — crashes on unsupported Node ABI versions (#89)
import { LocalBackend } from '../mcp/local/local-backend.js';
import { mountMCPEndpoints } from './mcp-http.js';
import { fork } from 'child_process';
@ -35,6 +33,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
import { JobManager } from './analyze-job.js';
import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@ -142,7 +141,7 @@ export const resolveWebDistDir = async (
return dir;
} catch (err: any) {
if (err?.code !== 'ENOENT') {
console.warn(`[serve] could not access web UI dir ${dir}:`, err.message);
logger.warn({ err: err.message }, `[serve] could not access web UI dir ${dir}:`);
}
}
}
@ -1481,7 +1480,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
});
})
.catch((err) => {
console.error('backend.init() failed after analyze:', err);
logger.error({ err }, 'backend.init() failed after analyze:');
jobManager.updateJob(job.id, {
status: 'failed',
error: 'Server failed to reload after analysis. Try again.',
@ -1513,7 +1512,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
j.retryCount++;
const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s
const lastErr = stderrChunks.trim().split('\n').pop() || '';
console.warn(
logger.warn(
`Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` +
(lastErr ? `: ${lastErr}` : ''),
);
@ -1775,7 +1774,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Global error handler — catch anything the route handlers miss
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('Unhandled error:', err);
logger.error({ err }, 'Unhandled error:');
res.status(500).json({ error: 'Internal server error' });
});
@ -1806,14 +1805,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Catch-all crash guards (mirrors startMCPServer in mcp/server.ts)
let shuttingDown = false;
process.on('uncaughtException', (err) => {
console.error('GitNexus uncaughtException:', err?.stack || err);
logger.error({ err: err?.stack || err }, 'GitNexus uncaughtException:');
if (!shuttingDown) {
shuttingDown = true;
shutdown().catch(() => {});
}
});
process.on('unhandledRejection', (reason: any) => {
console.error('GitNexus unhandledRejection:', reason?.stack || reason);
logger.error({ detail: reason?.stack || reason }, 'GitNexus unhandledRejection:');
});
});
};

View file

@ -10,6 +10,7 @@ import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
import { logger } from '../core/logger.js';
/** Root directory for all cloned repositories. Targets must resolve inside this. */
const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
@ -446,7 +447,7 @@ function runGit(args: string[], cwd?: string): Promise<void> {
if (code === 0) resolve();
else {
// Log full stderr internally but don't expose it to API callers (SSRF mitigation)
if (stderr.trim()) console.error(`git ${args[0]} stderr: ${stderr.trim()}`);
if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`);
reject(new Error(`git ${args[0]} failed (exit code ${code})`));
}
});

View file

@ -15,6 +15,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { createMCPServer } from '../mcp/server.js';
import type { LocalBackend } from '../mcp/local/local-backend.js';
import { randomUUID } from 'crypto';
import { logger } from '../core/logger.js';
interface MCPSession {
server: Server;
@ -87,7 +88,7 @@ export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Pr
app.all('/api/mcp', (req: Request, res: Response) => {
void handleMcpRequest(req, res).catch((err: any) => {
console.error('MCP HTTP request failed:', err);
logger.error({ err }, 'MCP HTTP request failed:');
if (res.headersSent) return;
res.status(500).json({
jsonrpc: '2.0',

View file

@ -39,17 +39,20 @@ describe('analyzeCommand worker timeout validation', () => {
it.each(['0', 'abc', '-5', 'Infinity'])(
'rejects invalid --worker-timeout value %s before analysis starts',
async (workerTimeout) => {
// CLI code (cli/analyze.ts) is exempt from the pino migration —
// user-facing stdout/stderr is the contract. Spy on console.error.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
// Import _captureLogger from the SAME module instance analyze.js will
// see — vi.resetModules() in beforeEach invalidates the singleton.
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { workerTimeout });
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(' --worker-timeout must be at least 1 second.\n');
expect(
cap.records().some((r) => r.msg === ' --worker-timeout must be at least 1 second.\n'),
).toBe(true);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
errorSpy.mockRestore();
cap.restore();
},
);

View file

@ -2,6 +2,7 @@ import path from 'node:path';
import http from 'node:http';
import express from 'express';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { _captureLogger } from '../../src/core/logger.js';
const { accessMock } = vi.hoisted(() => ({
accessMock: vi.fn(),
@ -213,7 +214,7 @@ describe('resolveWebDistDir', () => {
});
it('warns on non-ENOENT errors but continues', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const cap = _captureLogger();
accessMock.mockImplementation(async (p: string) => {
if (p.includes('primary'))
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
@ -222,11 +223,16 @@ describe('resolveWebDistDir', () => {
});
const result = await resolveWebDistDir('/primary', '/fallback');
expect(result).toBe('/fallback');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('could not access web UI dir /primary'),
'permission denied',
);
warnSpy.mockRestore();
expect(
cap
.records()
.some(
(r) =>
String(r.msg ?? '').includes('could not access web UI dir /primary') &&
r.err === 'permission denied',
),
).toBe(true);
cap.restore();
});
it('prefers GITNEXUS_WEB_DIST env var when set', async () => {