diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index b137d9d50..0fb2a98a0 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -1,6 +1,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; import { isHttpMode } from '../core/embeddings/http-client.js'; +import { checkLbugNative } from '../core/lbug/native-check.js'; import { t } from './i18n/index.js'; function isCombiningMark(codePoint: number): boolean { @@ -59,6 +60,13 @@ export const doctorCommand = async () => { console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`); console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`); console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`); + const nativeCheck = checkLbugNative(); + if (nativeCheck.ok) { + console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`); + } else { + console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`); + process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`); + } console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`); console.log(''); console.log(t('doctor.capabilities')); diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 91f6cdc13..67b34387a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import { createRequire } from 'node:module'; -import { createLazyAction } from './lazy-action.js'; +import { createLazyAction, createLbugLazyAction } from './lazy-action.js'; import { registerGroupCommands } from './group.js'; import { localizeCliHelp } from './help-i18n.js'; import { t } from './i18n/index.js'; @@ -89,7 +89,7 @@ program .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') .addHelpText('after', () => t('help.analyze.environment')) - .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); + .action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand')); program .command('index [path...]') @@ -105,12 +105,12 @@ program .description('Start local HTTP server for web UI connection') .option('-p, --port ', 'Port number', '4747') .option('--host ', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)') - .action(createLazyAction(() => import('./serve.js'), 'serveCommand')); + .action(createLbugLazyAction(() => import('./serve.js'), 'serveCommand')); program .command('mcp') .description('Start MCP server (stdio) — serves all indexed repos') - .action(createLazyAction(() => import('./mcp.js'), 'mcpCommand')); + .action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand')); program .command('list') @@ -177,12 +177,12 @@ program '--lang ', 'Output language for generated documentation (e.g. english, chinese, spanish, japanese)', ) - .action(createLazyAction(() => import('./wiki.js'), 'wikiCommand')); + .action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand')); program .command('augment ') .description('Augment a search pattern with knowledge graph context (used by hooks)') - .action(createLazyAction(() => import('./augment.js'), 'augmentCommand')); + .action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand')); program .command('publish [path]') @@ -207,7 +207,7 @@ program .option('-g, --goal ', 'What you want to find') .option('-l, --limit ', 'Max processes to return (default: 5)') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'queryCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand')); program .command('context [name]') @@ -216,7 +216,7 @@ program .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') .option('-f, --file ', 'File path to disambiguate common names') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'contextCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand')); program .command('impact ') @@ -228,13 +228,13 @@ program .option('--limit ', 'Max symbols per depth level (default: 100)') .option('--offset ', 'Skip N symbols per depth level for pagination') .option('--summary-only', 'Return counts and risk only, omit symbol list') - .action(createLazyAction(() => import('./tool.js'), 'impactCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand')); program .command('cypher ') .description('Execute raw Cypher query against the knowledge graph') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'cypherCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand')); program .command('detect-changes') @@ -243,7 +243,7 @@ program .option('-s, --scope ', 'What to analyze: unstaged, staged, all, or compare', 'unstaged') .option('-b, --base-ref ', 'Branch/commit for compare scope (e.g. main)') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand')); // ─── Eval Server (persistent daemon for SWE-bench) ───────────────── @@ -256,7 +256,7 @@ program 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') - .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); + .action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); registerGroupCommands(program); localizeCliHelp(program); diff --git a/gitnexus/src/cli/lazy-action.ts b/gitnexus/src/cli/lazy-action.ts index 0655a8f4b..3a7bea846 100644 --- a/gitnexus/src/cli/lazy-action.ts +++ b/gitnexus/src/cli/lazy-action.ts @@ -4,6 +4,8 @@ * at compile time — catching typos when used with concrete module imports. */ +import { checkLbugNative } from '../core/lbug/native-check.js'; + function isCallable(value: unknown): value is (...args: unknown[]) => unknown { return typeof value === 'function'; } @@ -21,3 +23,23 @@ export function createLazyAction< await action(...args); }; } + +export function createLbugLazyAction< + TModule extends Record, + TKey extends string & keyof TModule, +>(loader: () => Promise, exportName: TKey): (...args: unknown[]) => Promise { + return async (...args: unknown[]): Promise => { + const check = checkLbugNative(); + if (!check.ok) { + process.stderr.write(`\n ${check.message?.replace(/\n/g, '\n ')}\n\n`); + process.exitCode = 1; + return; + } + const module = await loader(); + const action = module[exportName]; + if (!isCallable(action)) { + throw new Error(`Lazy action export not found: ${exportName}`); + } + await action(...args); + }; +} diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts new file mode 100644 index 000000000..54d435bfd --- /dev/null +++ b/gitnexus/src/core/lbug/native-check.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'node:module'; + +export interface NativeCheckResult { + ok: boolean; + binaryPath?: string; + message?: string; +} + +export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { + let pkgDir: string; + + if (overridePkgDir) { + pkgDir = overridePkgDir; + } else { + try { + const _require = createRequire(import.meta.url); + const mainEntry = _require.resolve('@ladybugdb/core'); + pkgDir = path.dirname(mainEntry); + } catch { + return { + ok: false, + message: [ + 'LadybugDB package (@ladybugdb/core) is not installed.', + '', + 'Run: npm install', + ].join('\n'), + }; + } + } + + const binaryPath = path.join(pkgDir, 'lbugjs.node'); + if (!fs.existsSync(binaryPath)) { + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) is missing.', + '', + 'This usually happens when the install lifecycle script was skipped.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + '', + 'Also check that npm is not configured with ignore-scripts=true', + '(in .npmrc or via --ignore-scripts).', + ].join('\n'), + }; + } + + try { + const _require = createRequire(import.meta.url); + _require(binaryPath); + } catch (err: unknown) { + const nativeError = err instanceof Error ? err.message : String(err); + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${nativeError}`, + '', + 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + ].join('\n'), + }; + } + + return { ok: true, binaryPath }; +} diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts new file mode 100644 index 000000000..19bbab94a --- /dev/null +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { checkLbugNative } from '../../src/core/lbug/native-check.js'; + +describe('checkLbugNative', () => { + it('returns ok:true when the real @ladybugdb/core binary is present', () => { + const result = checkLbugNative(); + expect(result.ok).toBe(true); + expect(result.binaryPath).toBeDefined(); + expect(result.message).toBeUndefined(); + }); + + it('returns ok:false with repair instructions when lbugjs.node is missing', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('missing'); + expect(result.message).toContain('install.js'); + expect(result.message).toContain('trustedDependencies'); + expect(result.message).toContain('ignore-scripts'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns ok:false when lbugjs.node exists but is unloadable (zero-byte)', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), Buffer.alloc(0)); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('failed to load'); + expect(result.message).toContain('install.js'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +});