mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
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:
parent
100858f8c8
commit
613e5f4b0c
11 changed files with 337 additions and 14 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
87
gitnexus/test/unit/run-analyze-registry.test.ts
Normal file
87
gitnexus/test/unit/run-analyze-registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
48
gitnexus/test/unit/self-hosted-ui.test.ts
Normal file
48
gitnexus/test/unit/self-hosted-ui.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
gitnexus/test/unit/serve-command.test.ts
Normal file
38
gitnexus/test/unit/serve-command.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue