From 613e5f4b0c6ff65ac7284a8d72992469e5636784 Mon Sep 17 00:00:00 2001 From: kryptobaseddev Date: Fri, 10 Apr 2026 12:36:28 -0700 Subject: [PATCH] fix(serve): split self-hosted UI from the backend bridge Keep \ API-only so the hosted and dev UIs still have a clean backend target, and add an explicit self-hosted path for the bundled UI. Also re-register up-to-date local indexes so analyze-from-path works on fresh servers and make local source installs build reliably inside npm lifecycle scripts. --- README.md | 4 +- RUNBOOK.md | 3 + gitnexus/README.md | 6 +- gitnexus/scripts/build.js | 47 ++++++++-- gitnexus/src/cli/index.ts | 10 ++- gitnexus/src/cli/serve.ts | 18 +++- gitnexus/src/core/run-analyze.ts | 4 + gitnexus/src/server/api.ts | 86 +++++++++++++++++- .../test/unit/run-analyze-registry.test.ts | 87 +++++++++++++++++++ gitnexus/test/unit/self-hosted-ui.test.ts | 48 ++++++++++ gitnexus/test/unit/serve-command.test.ts | 38 ++++++++ 11 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 gitnexus/test/unit/run-analyze-registry.test.ts create mode 100644 gitnexus/test/unit/self-hosted-ui.test.ts create mode 100644 gitnexus/test/unit/serve-command.test.ts diff --git a/README.md b/README.md index 65ac7d332..7bbd0674a 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 | **Parsing** | Tree-sitter native bindings | Tree-sitter WASM | | **Privacy** | Everything local, no network | Everything in-browser, no server | -> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing. +> **Bridge mode:** `gitnexus serve` runs the local backend API for the hosted or dev web UI. Use `gitnexus serve-local` (or `gitnexus serve --ui`) to self-host the bundled UI and API together on one port. --- @@ -337,7 +337,7 @@ npm run dev The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. -**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. +**Local Backend Mode:** Run `gitnexus serve` and point the web UI at your local backend, or run `gitnexus serve-local` to self-host the bundled UI and API together. Both modes can browse your CLI-indexed repos without re-uploading or re-indexing. --- diff --git a/RUNBOOK.md b/RUNBOOK.md index 14eb60c59..da36f8fbe 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -109,6 +109,9 @@ Then re-run `npx gitnexus analyze` (and `--embeddings` if you need vectors). cd gitnexus npx gitnexus serve # default http://127.0.0.1:4747 — see serve --help for port/host + +# self-host bundled UI + API together +npx gitnexus serve-local ``` Use when the browser UI should talk to **local** indexed repos instead of WASM-only mode. diff --git a/gitnexus/README.md b/gitnexus/README.md index 7e87c93b4..fb8af5653 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -156,7 +156,9 @@ gitnexus analyze --embeddings # Enable embedding generation (slower, better s gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus mcp # Start MCP server (stdio) — serves all indexed repos -gitnexus serve # Start local HTTP server (multi-repo) for web UI +gitnexus serve # Start local HTTP API server (multi-repo) for web UI +gitnexus serve --ui # Serve the bundled local UI and API from one port +gitnexus serve-local # Same as `serve --ui` gitnexus index # Register an existing .gitnexus/ folder into the global registry gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo @@ -245,7 +247,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu GitNexus also has a browser-based UI at [gitnexus.vercel.app](https://gitnexus.vercel.app) — 100% client-side, your code never leaves the browser. -**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. +**Local Backend Mode:** Run `gitnexus serve` to expose the backend API to the hosted or dev web UI, or run `gitnexus serve-local` to self-host the bundled UI and API together. Both modes can browse your indexed repos without re-uploading or re-indexing. ## License diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 68378c61d..f16493309 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -1,12 +1,15 @@ #!/usr/bin/env node /** - * Build script that compiles gitnexus and inlines gitnexus-shared into the dist. + * Build script that compiles gitnexus, optionally bundles the local web UI, + * and inlines gitnexus-shared into the dist. * * Steps: * 1. Build gitnexus-shared (tsc) * 2. Build gitnexus (tsc) * 3. Copy gitnexus-shared/dist → dist/_shared - * 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths + * 4. Build gitnexus-web when present in the monorepo + * 5. Copy gitnexus-web/dist → dist/web when available + * 6. Rewrite bare 'gitnexus-shared' specifiers → relative paths */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; @@ -16,22 +19,54 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared'); +const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web'); const DIST = path.join(ROOT, 'dist'); const SHARED_DEST = path.join(DIST, '_shared'); +const WEB_DEST = path.join(DIST, 'web'); + +function resolveLocalBin(packageRoot, binName) { + const ext = process.platform === 'win32' ? '.cmd' : ''; + return path.join(packageRoot, 'node_modules', '.bin', `${binName}${ext}`); +} + +function runLocalBin(packageRoot, binName, args = '') { + const binPath = resolveLocalBin(packageRoot, binName); + if (!fs.existsSync(binPath)) { + throw new Error( + `Missing local binary: ${binName} in ${packageRoot}. Run npm install in that package first.`, + ); + } + + const quotedBin = JSON.stringify(binPath); + const command = args ? `${quotedBin} ${args}` : quotedBin; + execSync(command, { cwd: packageRoot, stdio: 'inherit' }); +} // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); -execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' }); +runLocalBin(SHARED_ROOT, 'tsc'); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' }); +runLocalBin(ROOT, 'tsc'); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); fs.cpSync(path.join(SHARED_ROOT, 'dist'), SHARED_DEST, { recursive: true }); -// ── 4. Rewrite imports ───────────────────────────────────────────── +// ── 4. Build bundled web UI when available ───────────────────────── +if (fs.existsSync(WEB_ROOT)) { + console.log('[build] compiling gitnexus-web…'); + execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit' }); + + console.log('[build] copying web UI into dist/web…'); + fs.rmSync(WEB_DEST, { recursive: true, force: true }); + fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true }); +} else { + console.log('[build] gitnexus-web not present, skipping bundled web UI build.'); +} + +// ── 5. Rewrite imports ───────────────────────────────────────────── console.log('[build] rewriting gitnexus-shared imports…'); let rewritten = 0; @@ -66,7 +101,7 @@ function walk(dir, extensions, cb) { walk(DIST, ['.js', '.d.ts'], rewriteFile); -// ── 5. Make CLI entry executable ──────────────────────────────────── +// ── 6. Make CLI entry executable ──────────────────────────────────── const cliEntry = path.join(DIST, 'cli', 'index.js'); if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755); diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 75940bcbf..c6f6982fa 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -45,11 +45,19 @@ program program .command('serve') - .description('Start local HTTP server for web UI connection') + .description('Start local HTTP API 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)') + .option('--ui', 'Also serve the bundled web UI from the same server') .action(createLazyAction(() => import('./serve.js'), 'serveCommand')); +program + .command('serve-local') + .description('Start local HTTP API server and bundled web UI') + .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'), 'serveLocalCommand')); + program .command('mcp') .description('Start MCP server (stdio) — serves all indexed repos') diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 9f0379306..fa81c8d8b 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -12,7 +12,13 @@ process.on('unhandledRejection', (reason: any) => { process.exit(1); }); -export const serveCommand = async (options?: { port?: string; host?: string }) => { +type ServeCommandOptions = { + port?: string; + host?: string; + ui?: boolean; +}; + +const runServeCommand = async (options: ServeCommandOptions = {}, serveWebUi: boolean = false) => { const port = Number(options?.port ?? 4747); // Default to 'localhost' so the OS decides whether to bind to 127.0.0.1 or // ::1 based on system configuration, avoiding spurious CORS errors when the @@ -20,7 +26,7 @@ export const serveCommand = async (options?: { port?: string; host?: string }) = const host = options?.host ?? 'localhost'; try { - await createServer(port, host); + await createServer(port, host, { serveWebUi: serveWebUi || Boolean(options?.ui) }); } catch (err: any) { console.error(`\nFailed to start GitNexus server:\n`); console.error(` ${err.message || err}\n`); @@ -35,3 +41,11 @@ export const serveCommand = async (options?: { port?: string; host?: string }) = process.exit(1); } }; + +export const serveCommand = async (options?: ServeCommandOptions) => { + await runServeCommand(options, false); +}; + +export const serveLocalCommand = async (options?: Omit) => { + await runServeCommand(options, true); +}; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e8a108c71..aaa8d0091 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -125,6 +125,10 @@ export async function runFullAnalysis( if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { + // Re-register existing indexes so fresh servers discover repos even when + // analysis short-circuits on an up-to-date .gitnexus folder. + await registerRepo(repoPath, existingMeta); + return { repoName: path.basename(repoPath), repoPath, diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index bb223696a..86bb2b92b 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -11,6 +11,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; +import { existsSync } from 'node:fs'; import fs from 'fs/promises'; import { createRequire } from 'node:module'; import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js'; @@ -37,6 +38,35 @@ import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js'; const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); +const SELF_HOSTED_UI_QUERY_PARAM = 'server'; + +export const buildSelfHostedUiRedirectUrl = ( + requestUrl: string, + serverBaseUrl: string, +): string => { + const url = new URL(requestUrl, serverBaseUrl); + url.searchParams.set(SELF_HOSTED_UI_QUERY_PARAM, serverBaseUrl); + return `${url.pathname}${url.search}`; +}; + +export const resolveBundledWebUiDist = ( + currentFileUrl: string = import.meta.url, + pathExists: (candidate: string) => boolean = existsSync, +): string | null => { + const baseDir = path.dirname(fileURLToPath(currentFileUrl)); + const candidates = [ + path.resolve(baseDir, '..', 'web'), + path.resolve(baseDir, '..', '..', '..', 'gitnexus-web', 'dist'), + ]; + + for (const candidate of candidates) { + if (pathExists(candidate)) { + return candidate; + } + } + + return null; +}; /** * Determine whether an HTTP Origin header value is allowed by CORS policy. @@ -424,7 +454,15 @@ const requestedRepo = (req: express.Request): string | undefined => { return undefined; }; -export const createServer = async (port: number, host: string = '127.0.0.1') => { +export interface CreateServerOptions { + serveWebUi?: boolean; +} + +export const createServer = async ( + port: number, + host: string = '127.0.0.1', + options: CreateServerOptions = {}, +) => { const app = express(); app.disable('x-powered-by'); @@ -1457,6 +1495,52 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' }); }); + const webUiDist = options.serveWebUi ? resolveBundledWebUiDist() : null; + if (webUiDist) { + const webUiIndex = path.join(webUiDist, 'index.html'); + + console.log(`Bundled web UI mounted from ${webUiDist}`); + + app.use( + express.static(webUiDist, { + index: false, + }), + ); + + app.get('/', (req, res) => { + if (!req.query[SELF_HOSTED_UI_QUERY_PARAM]) { + const serverBaseUrl = `${req.protocol}://${req.get('host')}`; + res.redirect(buildSelfHostedUiRedirectUrl(req.originalUrl, serverBaseUrl)); + return; + } + + res.sendFile(webUiIndex); + }); + + app.get('*', (req, res, next) => { + if (req.path === '/api' || req.path.startsWith('/api/')) { + next(); + return; + } + + if (path.extname(req.path)) { + next(); + return; + } + + if (!req.accepts('html')) { + next(); + return; + } + + res.sendFile(webUiIndex); + }); + } else { + if (options.serveWebUi) { + console.warn('Bundled web UI not found; serving API endpoints only.'); + } + } + // 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); diff --git a/gitnexus/test/unit/run-analyze-registry.test.ts b/gitnexus/test/unit/run-analyze-registry.test.ts new file mode 100644 index 000000000..3d25e2531 --- /dev/null +++ b/gitnexus/test/unit/run-analyze-registry.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockRunPipelineFromRepo = vi.fn(); +const mockGetStoragePaths = vi.fn(); +const mockSaveMeta = vi.fn(); +const mockLoadMeta = vi.fn(); +const mockAddToGitignore = vi.fn(); +const mockRegisterRepo = vi.fn(); +const mockCleanupOldKuzuFiles = vi.fn(); +const mockHasGitDir = vi.fn(); +const mockGetCurrentCommit = vi.fn(); +const mockGenerateAIContextFiles = vi.fn(); + +vi.mock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: mockRunPipelineFromRepo, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(), + loadGraphToLbug: vi.fn(), + getLbugStats: vi.fn(), + executeQuery: vi.fn(), + executeWithReusedStatement: vi.fn(), + closeLbug: vi.fn(), + createFTSIndex: vi.fn(), + loadCachedEmbeddings: vi.fn(), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: mockGetStoragePaths, + saveMeta: mockSaveMeta, + loadMeta: mockLoadMeta, + addToGitignore: mockAddToGitignore, + registerRepo: mockRegisterRepo, + cleanupOldKuzuFiles: mockCleanupOldKuzuFiles, +})); + +vi.mock('../../src/storage/git.js', () => ({ + getCurrentCommit: mockGetCurrentCommit, + hasGitDir: mockHasGitDir, +})); + +vi.mock('../../src/cli/ai-context.js', () => ({ + generateAIContextFiles: mockGenerateAIContextFiles, +})); + +describe('runFullAnalysis registry behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockGetStoragePaths.mockReturnValue({ + storagePath: '/mnt/projects/cleocode/.gitnexus', + lbugPath: '/mnt/projects/cleocode/.gitnexus/lbug', + }); + mockCleanupOldKuzuFiles.mockResolvedValue({ found: false, needsReindex: false }); + mockHasGitDir.mockReturnValue(true); + mockGetCurrentCommit.mockReturnValue('abc123'); + mockLoadMeta.mockResolvedValue({ + repoPath: '/mnt/projects/cleocode', + lastCommit: 'abc123', + indexedAt: '2026-04-10T00:00:00.000Z', + stats: { files: 10, nodes: 20, edges: 30 }, + }); + mockRegisterRepo.mockResolvedValue(undefined); + }); + + it('re-registers an up-to-date repo before returning early', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + + const result = await runFullAnalysis('/mnt/projects/cleocode', {}, { + onProgress: vi.fn(), + }); + + expect(result.alreadyUpToDate).toBe(true); + expect(result.repoName).toBe('cleocode'); + expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockRegisterRepo).toHaveBeenCalledWith( + '/mnt/projects/cleocode', + expect.objectContaining({ + repoPath: '/mnt/projects/cleocode', + lastCommit: 'abc123', + }), + ); + expect(mockRunPipelineFromRepo).not.toHaveBeenCalled(); + expect(mockGenerateAIContextFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/gitnexus/test/unit/self-hosted-ui.test.ts b/gitnexus/test/unit/self-hosted-ui.test.ts new file mode 100644 index 000000000..b72aa2829 --- /dev/null +++ b/gitnexus/test/unit/self-hosted-ui.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSelfHostedUiRedirectUrl, + resolveBundledWebUiDist, +} from '../../src/server/api.js'; + +describe('buildSelfHostedUiRedirectUrl', () => { + it('adds the current server base URL to the root route', () => { + expect(buildSelfHostedUiRedirectUrl('/', 'http://localhost:4747')).toBe( + '/?server=http%3A%2F%2Flocalhost%3A4747', + ); + }); + + it('preserves existing query params when redirecting to the self-hosted UI', () => { + expect(buildSelfHostedUiRedirectUrl('/?project=GitNexus', 'http://127.0.0.1:4747')).toBe( + '/?project=GitNexus&server=http%3A%2F%2F127.0.0.1%3A4747', + ); + }); +}); + +describe('resolveBundledWebUiDist', () => { + it('prefers packaged web assets inside dist/web', () => { + const result = resolveBundledWebUiDist( + 'file:///repo/gitnexus/dist/server/api.js', + (candidate) => candidate === '/repo/gitnexus/dist/web', + ); + + expect(result).toBe('/repo/gitnexus/dist/web'); + }); + + it('falls back to the sibling gitnexus-web/dist in the monorepo', () => { + const result = resolveBundledWebUiDist( + 'file:///repo/gitnexus/src/server/api.ts', + (candidate) => candidate === '/repo/gitnexus-web/dist', + ); + + expect(result).toBe('/repo/gitnexus-web/dist'); + }); + + it('returns null when no bundled UI is available', () => { + const result = resolveBundledWebUiDist( + 'file:///repo/gitnexus/src/server/api.ts', + () => false, + ); + + expect(result).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/serve-command.test.ts b/gitnexus/test/unit/serve-command.test.ts new file mode 100644 index 000000000..d1cda036c --- /dev/null +++ b/gitnexus/test/unit/serve-command.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCreateServer = vi.fn(); + +vi.mock('../../src/server/api.js', () => ({ + createServer: mockCreateServer, +})); + +describe('serve commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateServer.mockResolvedValue(undefined); + }); + + it('starts API-only mode by default', async () => { + const { serveCommand } = await import('../../src/cli/serve.js'); + + await serveCommand({ port: '4748', host: '127.0.0.1' }); + + expect(mockCreateServer).toHaveBeenCalledWith(4748, '127.0.0.1', { serveWebUi: false }); + }); + + it('enables the bundled UI when serve receives --ui', async () => { + const { serveCommand } = await import('../../src/cli/serve.js'); + + await serveCommand({ ui: true }); + + expect(mockCreateServer).toHaveBeenCalledWith(4747, 'localhost', { serveWebUi: true }); + }); + + it('serve-local always enables the bundled UI', async () => { + const { serveLocalCommand } = await import('../../src/cli/serve.js'); + + await serveLocalCommand({ port: '4849' }); + + expect(mockCreateServer).toHaveBeenCalledWith(4849, 'localhost', { serveWebUi: true }); + }); +});