Skip to content

Commit 139f730

Browse files
bwlclaude
andcommitted
Add Elysia server foundation with health and stats endpoints
Phase 1 of Forest server mode implementation: - Extract core business logic into src/core/ (health, stats) - Add Bun + Elysia server with REST API (src/server/) - Implement /api/v1/health and /api/v1/stats endpoints - Add 'forest serve' CLI command - Include auto-generated Swagger/OpenAPI docs at /swagger - Refactor CLI commands to use shared core modules Server runs at http://localhost:3000 with CORS and consistent JSON response envelopes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7dda365 commit 139f730

11 files changed

Lines changed: 1010 additions & 313 deletions

File tree

bun.lock

Lines changed: 417 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"build": "tsc",
1010
"lint": "tsc --noEmit",
1111
"dev": "ts-node src/index.ts",
12+
"dev:server": "bun run src/server/index.ts",
1213
"start": "node dist/index.js"
1314
},
1415
"keywords": [
@@ -20,8 +21,11 @@
2021
"author": "",
2122
"license": "MIT",
2223
"dependencies": {
24+
"@elysiajs/cors": "^1.1.1",
25+
"@elysiajs/swagger": "^1.1.5",
2326
"@xenova/transformers": "^2.17.2",
2427
"clerc": "^0.44.0",
28+
"elysia": "^1.1.22",
2529
"graphology": "^0.25.1",
2630
"sql.js": "^1.11.0"
2731
},

src/cli/commands/health.ts

Lines changed: 3 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import fs from 'fs';
2-
import path from 'path';
3-
4-
import { getEmbeddingProvider, embeddingsEnabled } from '../../lib/embeddings';
1+
import { getHealthReport, isHealthy, HealthCheck } from '../../core/health';
52
import { handleError } from '../shared/utils';
63

74
type ClercModule = typeof import('clerc');
@@ -10,8 +7,6 @@ type HealthFlags = {
107
json?: boolean;
118
};
129

13-
const DEFAULT_DB_PATH = 'forest.db';
14-
1510
export function createHealthCommand(clerc: ClercModule) {
1611
return clerc.defineCommand(
1712
{
@@ -34,32 +29,8 @@ export function createHealthCommand(clerc: ClercModule) {
3429
);
3530
}
3631

37-
type HealthCheck = {
38-
status: 'ok' | 'warning' | 'error';
39-
message: string;
40-
};
41-
42-
type HealthReport = {
43-
database: HealthCheck & { path?: string; sizeBytes?: number };
44-
embeddingProvider: HealthCheck & { provider?: string };
45-
openaiKey?: HealthCheck;
46-
localTransformer?: HealthCheck;
47-
};
48-
4932
async function runHealth(flags: HealthFlags) {
50-
const report: HealthReport = {
51-
database: await checkDatabase(),
52-
embeddingProvider: await checkEmbeddingProvider(),
53-
};
54-
55-
const provider = getEmbeddingProvider();
56-
if (provider === 'openai') {
57-
report.openaiKey = checkOpenAIKey();
58-
}
59-
60-
if (provider === 'local') {
61-
report.localTransformer = await checkLocalTransformer();
62-
}
33+
const report = await getHealthReport();
6334

6435
if (flags.json) {
6536
console.log(JSON.stringify(report, null, 2));
@@ -95,14 +66,7 @@ async function runHealth(flags: HealthFlags) {
9566
console.log('');
9667
}
9768

98-
const allOk = [
99-
report.database.status === 'ok',
100-
report.embeddingProvider.status === 'ok',
101-
!report.openaiKey || report.openaiKey.status === 'ok',
102-
!report.localTransformer || report.localTransformer.status === 'ok',
103-
].every(Boolean);
104-
105-
if (allOk) {
69+
if (isHealthy(report)) {
10670
console.log('✔ All systems operational');
10771
} else {
10872
console.log('⚠ Some checks failed. Review the output above.');
@@ -114,155 +78,3 @@ function printCheck(label: string, check: HealthCheck) {
11478
const icon = check.status === 'ok' ? '✔' : check.status === 'warning' ? '⚠' : '✖';
11579
console.log(`${icon} ${label}: ${check.message}`);
11680
}
117-
118-
async function checkDatabase(): Promise<HealthCheck & { path?: string; sizeBytes?: number }> {
119-
const dbPath = process.env.FOREST_DB_PATH ?? DEFAULT_DB_PATH;
120-
const resolvedPath = path.resolve(dbPath);
121-
122-
if (!fs.existsSync(resolvedPath)) {
123-
return {
124-
status: 'warning',
125-
message: 'Database file does not exist yet (will be created on first capture)',
126-
path: resolvedPath,
127-
};
128-
}
129-
130-
try {
131-
const stats = fs.statSync(resolvedPath);
132-
if (!stats.isFile()) {
133-
return {
134-
status: 'error',
135-
message: 'Database path exists but is not a file',
136-
path: resolvedPath,
137-
};
138-
}
139-
140-
// Try to read the first few bytes to verify it's accessible
141-
const fd = fs.openSync(resolvedPath, 'r');
142-
const buffer = Buffer.alloc(16);
143-
fs.readSync(fd, buffer, 0, 16, 0);
144-
fs.closeSync(fd);
145-
146-
// Check if it looks like a SQLite file (magic: "SQLite format 3\0")
147-
const magic = buffer.toString('utf-8', 0, 15);
148-
if (!magic.startsWith('SQLite format 3')) {
149-
return {
150-
status: 'warning',
151-
message: 'File exists but may not be a valid SQLite database',
152-
path: resolvedPath,
153-
sizeBytes: stats.size,
154-
};
155-
}
156-
157-
return {
158-
status: 'ok',
159-
message: 'Database file is accessible and valid',
160-
path: resolvedPath,
161-
sizeBytes: stats.size,
162-
};
163-
} catch (error) {
164-
return {
165-
status: 'error',
166-
message: `Cannot access database file: ${error instanceof Error ? error.message : String(error)}`,
167-
path: resolvedPath,
168-
};
169-
}
170-
}
171-
172-
async function checkEmbeddingProvider(): Promise<HealthCheck & { provider?: string }> {
173-
const provider = getEmbeddingProvider();
174-
const enabled = embeddingsEnabled();
175-
176-
if (!enabled) {
177-
return {
178-
status: 'warning',
179-
message: 'Embeddings are disabled (pure lexical scoring)',
180-
provider,
181-
};
182-
}
183-
184-
if (provider === 'mock') {
185-
return {
186-
status: 'warning',
187-
message: 'Using mock embeddings (deterministic, non-semantic)',
188-
provider,
189-
};
190-
}
191-
192-
if (provider === 'openai') {
193-
return {
194-
status: 'ok',
195-
message: 'Using OpenAI embeddings',
196-
provider,
197-
};
198-
}
199-
200-
if (provider === 'local') {
201-
return {
202-
status: 'ok',
203-
message: 'Using local transformer embeddings',
204-
provider,
205-
};
206-
}
207-
208-
return {
209-
status: 'ok',
210-
message: `Provider: ${provider}`,
211-
provider,
212-
};
213-
}
214-
215-
function checkOpenAIKey(): HealthCheck {
216-
const apiKey = process.env.OPENAI_API_KEY;
217-
218-
if (!apiKey) {
219-
return {
220-
status: 'error',
221-
message: 'OPENAI_API_KEY is not set (required for OpenAI provider)',
222-
};
223-
}
224-
225-
if (apiKey.trim().length === 0) {
226-
return {
227-
status: 'error',
228-
message: 'OPENAI_API_KEY is empty',
229-
};
230-
}
231-
232-
if (!apiKey.startsWith('sk-')) {
233-
return {
234-
status: 'warning',
235-
message: 'OPENAI_API_KEY format looks unusual (expected to start with "sk-")',
236-
};
237-
}
238-
239-
return {
240-
status: 'ok',
241-
message: 'OPENAI_API_KEY is set and format looks valid',
242-
};
243-
}
244-
245-
async function checkLocalTransformer(): Promise<HealthCheck> {
246-
try {
247-
// Try to import the transformers module
248-
const mod = await import('@xenova/transformers');
249-
if (!mod || typeof mod.pipeline !== 'function') {
250-
return {
251-
status: 'error',
252-
message: '@xenova/transformers is installed but pipeline function not found',
253-
};
254-
}
255-
256-
// We don't actually load the model here to avoid startup delay,
257-
// but we verify the package is available
258-
return {
259-
status: 'ok',
260-
message: '@xenova/transformers is installed and available',
261-
};
262-
} catch (error) {
263-
return {
264-
status: 'error',
265-
message: `@xenova/transformers is not available: ${error instanceof Error ? error.message : String(error)}`,
266-
};
267-
}
268-
}

src/cli/commands/serve.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { handleError } from '../shared/utils';
2+
3+
type ClercModule = typeof import('clerc');
4+
5+
type ServeFlags = {
6+
port?: number;
7+
host?: string;
8+
};
9+
10+
const DEFAULT_PORT = 3000;
11+
const DEFAULT_HOST = 'localhost';
12+
13+
export function createServeCommand(clerc: ClercModule) {
14+
return clerc.defineCommand(
15+
{
16+
name: 'serve',
17+
description: 'Start Forest API server (requires Bun runtime)',
18+
flags: {
19+
port: {
20+
type: Number,
21+
description: 'Port to listen on',
22+
default: DEFAULT_PORT,
23+
},
24+
host: {
25+
type: String,
26+
description: 'Host to bind to',
27+
default: DEFAULT_HOST,
28+
},
29+
},
30+
},
31+
async ({ flags }) => {
32+
try {
33+
await runServe(flags as ServeFlags);
34+
} catch (error) {
35+
handleError(error);
36+
}
37+
},
38+
);
39+
}
40+
41+
async function runServe(flags: ServeFlags) {
42+
// Check if running under Bun
43+
if (typeof Bun === 'undefined') {
44+
console.error('❌ Error: forest serve requires Bun runtime');
45+
console.error('');
46+
console.error('To start the Forest server:');
47+
console.error(' 1. Install Bun: https://bun.sh');
48+
console.error(' 2. Run: bun run src/server/index.ts');
49+
console.error(' Or use: npm run dev:server');
50+
process.exit(1);
51+
}
52+
53+
const port = flags.port ?? DEFAULT_PORT;
54+
55+
// Dynamically import the server (only works with Bun)
56+
const { startServer } = await import('../../server/index.js');
57+
await startServer({ port });
58+
}

0 commit comments

Comments
 (0)