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.
This commit is contained in:
kryptobaseddev 2026-04-10 12:36:28 -07:00
parent 100858f8c8
commit 613e5f4b0c
11 changed files with 337 additions and 14 deletions

View file

@ -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.
---

View file

@ -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.

View file

@ -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

View file

@ -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);

View file

@ -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>', 'Port number', '4747')
.option('--host <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>', 'Port number', '4747')
.option('--host <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')

View file

@ -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<ServeCommandOptions, 'ui'>) => {
await runServeCommand(options, true);
};

View file

@ -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,

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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 });
});
});