readthemanual 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "docs-search",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "dist/cli.js",
6
+ "bin": {
7
+ "docs-search": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/cli.ts",
12
+ "start": "node dist/cli.js"
13
+ },
14
+ "keywords": [],
15
+ "author": "",
16
+ "license": "ISC",
17
+ "type": "commonjs",
18
+ "dependencies": {
19
+ "@hono/node-server": "^1.19.9",
20
+ "better-sqlite3": "^12.6.2",
21
+ "commander": "^14.0.3",
22
+ "hono": "^4.12.3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/better-sqlite3": "^7.6.13",
26
+ "@types/node": "^25.3.3",
27
+ "tsx": "^4.21.0",
28
+ "typescript": "^5.9.3"
29
+ }
30
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { ingest } from "./ingest";
4
+ import { startServer } from "./server";
5
+ import { openDb, search, getDocument, listDocuments, DEFAULT_DB_PATH } from "./db";
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name("docs-search")
11
+ .description("SQLite FTS5 search service for ZeroClaw docs")
12
+ .version("1.0.0");
13
+
14
+ program
15
+ .command("ingest")
16
+ .description("Fetch and index ZeroClaw docs from GitHub")
17
+ .option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
18
+ .option("--all-langs", "Index all languages, not just English", false)
19
+ .option("-v, --verbose", "Verbose output", false)
20
+ .action(async (opts) => {
21
+ const result = await ingest({
22
+ dbPath: opts.db,
23
+ allLangs: opts.allLangs,
24
+ verbose: opts.verbose,
25
+ });
26
+ console.log(
27
+ `Indexed ${result.filesProcessed} files (${result.sectionsInserted} sections).`
28
+ );
29
+ });
30
+
31
+ program
32
+ .command("serve")
33
+ .description("Start the JSON-RPC HTTP server")
34
+ .option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
35
+ .option("-p, --port <number>", "Port to listen on", "3777")
36
+ .action((opts) => {
37
+ startServer({ dbPath: opts.db, port: parseInt(opts.port, 10) });
38
+ });
39
+
40
+ program
41
+ .command("search <query>")
42
+ .description("Search the docs index")
43
+ .option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
44
+ .option("-l, --lang <language>", "Filter by language")
45
+ .option("-n, --limit <number>", "Max results", "10")
46
+ .option("--json", "Output raw JSON", false)
47
+ .action((query, opts) => {
48
+ const db = openDb(opts.db);
49
+ const { results, total } = search(db, query, {
50
+ language: opts.lang,
51
+ limit: parseInt(opts.limit, 10),
52
+ });
53
+ db.close();
54
+ if (opts.json) {
55
+ console.log(JSON.stringify({ results, total }, null, 2));
56
+ return;
57
+ }
58
+ if (results.length === 0) {
59
+ console.log("No results found.");
60
+ return;
61
+ }
62
+ for (const r of results) {
63
+ const snippet = r.snippet.replace(/<\/?mark>/g, (m) => m === "<mark>" ? "\x1b[1;33m" : "\x1b[0m");
64
+ console.log(`\x1b[36m[${r.id}]\x1b[0m \x1b[1m${r.heading}\x1b[0m`);
65
+ console.log(` ${r.file_path} (${r.language})`);
66
+ console.log(` ${snippet}`);
67
+ console.log();
68
+ }
69
+ console.log(`${results.length} of ${total} match(es)`);
70
+ });
71
+
72
+ program
73
+ .command("get <id>")
74
+ .description("Get a document section by ID")
75
+ .option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
76
+ .option("--json", "Output raw JSON", false)
77
+ .action((id, opts) => {
78
+ const db = openDb(opts.db);
79
+ const doc = getDocument(db, parseInt(id, 10));
80
+ db.close();
81
+ if (!doc) {
82
+ console.error(`Document ${id} not found.`);
83
+ process.exit(1);
84
+ }
85
+ if (opts.json) {
86
+ console.log(JSON.stringify(doc, null, 2));
87
+ return;
88
+ }
89
+ console.log(`\x1b[1m${doc.heading}\x1b[0m`);
90
+ console.log(`${doc.file_path} (${doc.language})`);
91
+ console.log();
92
+ console.log(doc.content);
93
+ });
94
+
95
+ program
96
+ .command("list")
97
+ .description("List indexed documents")
98
+ .option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
99
+ .option("-l, --lang <language>", "Filter by language")
100
+ .option("-n, --limit <number>", "Max results", "50")
101
+ .option("-o, --offset <number>", "Offset", "0")
102
+ .option("--json", "Output raw JSON", false)
103
+ .action((opts) => {
104
+ const db = openDb(opts.db);
105
+ const result = listDocuments(db, {
106
+ language: opts.lang,
107
+ limit: parseInt(opts.limit, 10),
108
+ offset: parseInt(opts.offset, 10),
109
+ });
110
+ db.close();
111
+ if (opts.json) {
112
+ console.log(JSON.stringify(result, null, 2));
113
+ return;
114
+ }
115
+ for (const d of result.docs) {
116
+ console.log(`\x1b[36m[${d.id}]\x1b[0m ${d.file_path} — ${d.heading} (${d.language})`);
117
+ }
118
+ console.log(`\nShowing ${result.docs.length} of ${result.total} total sections`);
119
+ });
120
+
121
+ // Default: bare args with no subcommand → search
122
+ const knownCommands = ["ingest", "serve", "search", "get", "list", "help"];
123
+ const firstArg = process.argv[2];
124
+ if (firstArg && !firstArg.startsWith("-") && !knownCommands.includes(firstArg)) {
125
+ // Rewrite argv to inject "search" before the query
126
+ process.argv.splice(2, 0, "search");
127
+ }
128
+
129
+ program.parse();
@@ -0,0 +1,192 @@
1
+ import Database from "better-sqlite3";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+
6
+ const DEFAULT_DB_DIR = path.join(os.homedir(), ".local", "docs-search");
7
+ const DEFAULT_DB_PATH = path.join(DEFAULT_DB_DIR, "docs-search.db");
8
+
9
+ export interface DocSection {
10
+ id: number;
11
+ file_path: string;
12
+ heading: string;
13
+ content: string;
14
+ language: string;
15
+ }
16
+
17
+ export interface SearchResult extends DocSection {
18
+ rank: number;
19
+ snippet: string;
20
+ }
21
+
22
+ export { DEFAULT_DB_PATH };
23
+
24
+ export function openDb(dbPath: string = DEFAULT_DB_PATH): Database.Database {
25
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
26
+ const db = new Database(dbPath);
27
+ db.pragma("journal_mode = WAL");
28
+ db.pragma("foreign_keys = ON");
29
+ initSchema(db);
30
+ return db;
31
+ }
32
+
33
+ function initSchema(db: Database.Database): void {
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS docs (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ file_path TEXT NOT NULL,
38
+ heading TEXT NOT NULL,
39
+ content TEXT NOT NULL,
40
+ language TEXT NOT NULL DEFAULT 'en'
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_docs_path ON docs(file_path);
44
+ CREATE INDEX IF NOT EXISTS idx_docs_lang ON docs(language);
45
+
46
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
47
+ heading,
48
+ content,
49
+ content='docs',
50
+ content_rowid='id',
51
+ tokenize='porter unicode61'
52
+ );
53
+
54
+ CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
55
+ INSERT INTO docs_fts(rowid, heading, content)
56
+ VALUES (new.id, new.heading, new.content);
57
+ END;
58
+
59
+ CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
60
+ INSERT INTO docs_fts(docs_fts, rowid, heading, content)
61
+ VALUES ('delete', old.id, old.heading, old.content);
62
+ END;
63
+
64
+ CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
65
+ INSERT INTO docs_fts(docs_fts, rowid, heading, content)
66
+ VALUES ('delete', old.id, old.heading, old.content);
67
+ INSERT INTO docs_fts(rowid, heading, content)
68
+ VALUES (new.id, new.heading, new.content);
69
+ END;
70
+ `);
71
+ }
72
+
73
+ export function clearDocs(db: Database.Database): void {
74
+ db.exec("DELETE FROM docs");
75
+ }
76
+
77
+ export function insertSection(
78
+ db: Database.Database,
79
+ section: Omit<DocSection, "id">
80
+ ): number {
81
+ const stmt = db.prepare(
82
+ "INSERT INTO docs (file_path, heading, content, language) VALUES (?, ?, ?, ?)"
83
+ );
84
+ const result = stmt.run(
85
+ section.file_path,
86
+ section.heading,
87
+ section.content,
88
+ section.language
89
+ );
90
+ return Number(result.lastInsertRowid);
91
+ }
92
+
93
+ export interface SearchResponse {
94
+ results: SearchResult[];
95
+ total: number;
96
+ }
97
+
98
+ function sanitizeFtsQuery(query: string): string {
99
+ // Split into tokens and wrap each in double quotes so FTS5 treats them as
100
+ // literals (handles dashes, slashes, backslashes, etc.). Any embedded
101
+ // double-quote characters are removed to avoid breaking the quoting.
102
+ const tokens = query
103
+ .replace(/"/g, "")
104
+ .split(/\s+/)
105
+ .filter(Boolean);
106
+ if (tokens.length === 0) return '""';
107
+ return tokens.map((t) => `"${t}"`).join(" ");
108
+ }
109
+
110
+ export function search(
111
+ db: Database.Database,
112
+ query: string,
113
+ opts: { language?: string; limit?: number } = {}
114
+ ): SearchResponse {
115
+ const limit = opts.limit ?? 20;
116
+ const ftsQuery = sanitizeFtsQuery(query);
117
+
118
+ let countSql = `
119
+ SELECT COUNT(*) as total
120
+ FROM docs_fts
121
+ JOIN docs d ON d.id = docs_fts.rowid
122
+ WHERE docs_fts MATCH ?
123
+ `;
124
+ let sql = `
125
+ SELECT
126
+ d.id, d.file_path, d.heading, d.content, d.language,
127
+ docs_fts.rank AS rank,
128
+ snippet(docs_fts, 1, '<mark>', '</mark>', '...', 40) AS snippet
129
+ FROM docs_fts
130
+ JOIN docs d ON d.id = docs_fts.rowid
131
+ WHERE docs_fts MATCH ?
132
+ `;
133
+ const params: (string | number)[] = [ftsQuery];
134
+ const countParams: (string | number)[] = [ftsQuery];
135
+
136
+ if (opts.language) {
137
+ countSql += " AND d.language = ?";
138
+ sql += " AND d.language = ?";
139
+ params.push(opts.language);
140
+ countParams.push(opts.language);
141
+ }
142
+
143
+ sql += " ORDER BY docs_fts.rank LIMIT ?";
144
+ params.push(limit);
145
+
146
+ const { total } = db.prepare(countSql).get(...countParams) as { total: number };
147
+ const results = db.prepare(sql).all(...params) as SearchResult[];
148
+
149
+ return { results, total };
150
+ }
151
+
152
+ export function getDocument(
153
+ db: Database.Database,
154
+ id: number
155
+ ): DocSection | null {
156
+ return (
157
+ (db.prepare("SELECT * FROM docs WHERE id = ?").get(id) as
158
+ | DocSection
159
+ | undefined) ?? null
160
+ );
161
+ }
162
+
163
+ export function listDocuments(
164
+ db: Database.Database,
165
+ opts: { language?: string; limit?: number; offset?: number } = {}
166
+ ): { docs: Pick<DocSection, "id" | "file_path" | "heading" | "language">[]; total: number } {
167
+ const limit = opts.limit ?? 100;
168
+ const offset = opts.offset ?? 0;
169
+
170
+ let countSql = "SELECT COUNT(*) as total FROM docs";
171
+ let sql = "SELECT id, file_path, heading, language FROM docs";
172
+ const params: (string | number)[] = [];
173
+ const countParams: string[] = [];
174
+
175
+ if (opts.language) {
176
+ countSql += " WHERE language = ?";
177
+ sql += " WHERE language = ?";
178
+ params.push(opts.language);
179
+ countParams.push(opts.language);
180
+ }
181
+
182
+ sql += " ORDER BY file_path, id LIMIT ? OFFSET ?";
183
+ params.push(limit, offset);
184
+
185
+ const { total } = db.prepare(countSql).get(...countParams) as { total: number };
186
+ const docs = db.prepare(sql).all(...params) as Pick<
187
+ DocSection,
188
+ "id" | "file_path" | "heading" | "language"
189
+ >[];
190
+
191
+ return { docs, total };
192
+ }
@@ -0,0 +1,168 @@
1
+ import { openDb, clearDocs, insertSection } from "./db";
2
+ import type Database from "better-sqlite3";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+
7
+ const DOCS_DIR = path.join(os.homedir(), ".local", "docs-search", "docs");
8
+
9
+ const REPO = "zeroclaw-labs/zeroclaw";
10
+ const BRANCH = "main";
11
+ const DOCS_PREFIX = "docs/";
12
+
13
+ interface TreeEntry {
14
+ path: string;
15
+ type: string;
16
+ url: string;
17
+ }
18
+
19
+ async function fetchTree(): Promise<TreeEntry[]> {
20
+ const url = `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`;
21
+ const res = await fetch(url, {
22
+ headers: {
23
+ Accept: "application/vnd.github.v3+json",
24
+ ...(process.env.GITHUB_TOKEN
25
+ ? { Authorization: `token ${process.env.GITHUB_TOKEN}` }
26
+ : {}),
27
+ },
28
+ });
29
+ if (!res.ok) throw new Error(`GitHub tree API: ${res.status} ${res.statusText}`);
30
+ const data = (await res.json()) as { tree: TreeEntry[] };
31
+ return data.tree;
32
+ }
33
+
34
+ async function fetchRawFile(filePath: string): Promise<string> {
35
+ const url = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${filePath}`;
36
+ const res = await fetch(url);
37
+ if (!res.ok) throw new Error(`Failed to fetch ${filePath}: ${res.status}`);
38
+ return res.text();
39
+ }
40
+
41
+ function detectLanguage(filePath: string): string {
42
+ const match = filePath.match(/^docs\/i18n\/([a-z]{2}(?:-[A-Z]{2})?)\//);
43
+ if (match) return match[1];
44
+ return "en";
45
+ }
46
+
47
+ interface Section {
48
+ heading: string;
49
+ content: string;
50
+ }
51
+
52
+ function splitByHeadings(markdown: string): Section[] {
53
+ const lines = markdown.split("\n");
54
+ const sections: Section[] = [];
55
+ let currentHeading = "(intro)";
56
+ let currentLines: string[] = [];
57
+
58
+ for (const line of lines) {
59
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
60
+ if (headingMatch) {
61
+ if (currentLines.length > 0) {
62
+ const content = currentLines.join("\n").trim();
63
+ if (content.length > 0) {
64
+ sections.push({ heading: currentHeading, content });
65
+ }
66
+ }
67
+ currentHeading = headingMatch[2];
68
+ currentLines = [];
69
+ } else {
70
+ currentLines.push(line);
71
+ }
72
+ }
73
+
74
+ if (currentLines.length > 0) {
75
+ const content = currentLines.join("\n").trim();
76
+ if (content.length > 0) {
77
+ sections.push({ heading: currentHeading, content });
78
+ }
79
+ }
80
+
81
+ return sections;
82
+ }
83
+
84
+ export async function ingest(opts: {
85
+ dbPath?: string;
86
+ allLangs?: boolean;
87
+ verbose?: boolean;
88
+ }): Promise<{ filesProcessed: number; sectionsInserted: number }> {
89
+ const db = openDb(opts.dbPath);
90
+ const log = opts.verbose ? console.log : () => {};
91
+
92
+ log("Fetching file tree from GitHub...");
93
+ const tree = await fetchTree();
94
+
95
+ const mdFiles = tree.filter((entry) => {
96
+ if (entry.type !== "blob") return false;
97
+ if (!entry.path.startsWith(DOCS_PREFIX)) return false;
98
+ if (!entry.path.endsWith(".md")) return false;
99
+ // Skip images and non-text
100
+ if (/\.(jpg|png|svg|gif)$/i.test(entry.path)) return false;
101
+ // Filter by language
102
+ if (!opts.allLangs) {
103
+ const lang = detectLanguage(entry.path);
104
+ if (lang !== "en") return false;
105
+ }
106
+ return true;
107
+ });
108
+
109
+ log(`Found ${mdFiles.length} markdown files to index.`);
110
+ log(`Saving docs to ${DOCS_DIR}`);
111
+
112
+ // Clean and recreate local docs directory
113
+ fs.rmSync(DOCS_DIR, { recursive: true, force: true });
114
+ fs.mkdirSync(DOCS_DIR, { recursive: true });
115
+
116
+ clearDocs(db);
117
+
118
+ const insertMany = db.transaction(
119
+ (sections: { file_path: string; heading: string; content: string; language: string }[]) => {
120
+ for (const s of sections) {
121
+ insertSection(db, s);
122
+ }
123
+ }
124
+ );
125
+
126
+ let filesProcessed = 0;
127
+ let sectionsInserted = 0;
128
+ const allSections: { file_path: string; heading: string; content: string; language: string }[] = [];
129
+
130
+ // Fetch in batches of 10 to avoid rate limits
131
+ const batchSize = 10;
132
+ for (let i = 0; i < mdFiles.length; i += batchSize) {
133
+ const batch = mdFiles.slice(i, i + batchSize);
134
+ const results = await Promise.all(
135
+ batch.map(async (entry) => {
136
+ const content = await fetchRawFile(entry.path);
137
+ return { path: entry.path, content };
138
+ })
139
+ );
140
+
141
+ for (const { path: filePath, content } of results) {
142
+ // Save raw markdown to ~/.local/docs-search/docs/
143
+ const localPath = path.join(DOCS_DIR, filePath);
144
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
145
+ fs.writeFileSync(localPath, content, "utf-8");
146
+
147
+ const lang = detectLanguage(filePath);
148
+ const sections = splitByHeadings(content);
149
+ for (const section of sections) {
150
+ allSections.push({
151
+ file_path: filePath,
152
+ heading: section.heading,
153
+ content: section.content,
154
+ language: lang,
155
+ });
156
+ }
157
+ filesProcessed++;
158
+ log(` [${filesProcessed}/${mdFiles.length}] ${filePath} (${sections.length} sections)`);
159
+ }
160
+ }
161
+
162
+ insertMany(allSections);
163
+ sectionsInserted = allSections.length;
164
+
165
+ db.close();
166
+ log(`Done. ${filesProcessed} files, ${sectionsInserted} sections indexed.`);
167
+ return { filesProcessed, sectionsInserted };
168
+ }
@@ -0,0 +1,145 @@
1
+ import { Hono } from "hono";
2
+ import { serve } from "@hono/node-server";
3
+ import { openDb, search, getDocument, listDocuments } from "./db";
4
+ import type Database from "better-sqlite3";
5
+
6
+ interface JsonRpcRequest {
7
+ jsonrpc: "2.0";
8
+ id: string | number | null;
9
+ method: string;
10
+ params?: Record<string, unknown>;
11
+ }
12
+
13
+ interface JsonRpcResponse {
14
+ jsonrpc: "2.0";
15
+ id: string | number | null;
16
+ result?: unknown;
17
+ error?: { code: number; message: string; data?: unknown };
18
+ }
19
+
20
+ function rpcError(
21
+ id: string | number | null,
22
+ code: number,
23
+ message: string
24
+ ): JsonRpcResponse {
25
+ return { jsonrpc: "2.0", id, error: { code, message } };
26
+ }
27
+
28
+ function rpcResult(id: string | number | null, result: unknown): JsonRpcResponse {
29
+ return { jsonrpc: "2.0", id, result };
30
+ }
31
+
32
+ function handleRpc(db: Database.Database, req: JsonRpcRequest): JsonRpcResponse {
33
+ const { id, method, params } = req;
34
+
35
+ switch (method) {
36
+ case "search": {
37
+ const query = params?.query;
38
+ if (typeof query !== "string" || query.trim().length === 0) {
39
+ return rpcError(id, -32602, "params.query is required and must be a non-empty string");
40
+ }
41
+ const language = typeof params?.language === "string" ? params.language : undefined;
42
+ const limit = typeof params?.limit === "number" ? params.limit : undefined;
43
+ const { results, total } = search(db, query, { language, limit });
44
+ return rpcResult(id, { results, total, count: results.length });
45
+ }
46
+
47
+ case "get_document": {
48
+ const docId = params?.id;
49
+ if (typeof docId !== "number") {
50
+ return rpcError(id, -32602, "params.id is required and must be a number");
51
+ }
52
+ const doc = getDocument(db, docId);
53
+ if (!doc) {
54
+ return rpcError(id, -32602, `Document with id ${docId} not found`);
55
+ }
56
+ return rpcResult(id, doc);
57
+ }
58
+
59
+ case "list_documents": {
60
+ const language = typeof params?.language === "string" ? params.language : undefined;
61
+ const limit = typeof params?.limit === "number" ? params.limit : undefined;
62
+ const offset = typeof params?.offset === "number" ? params.offset : undefined;
63
+ const result = listDocuments(db, { language, limit, offset });
64
+ return rpcResult(id, result);
65
+ }
66
+
67
+ default:
68
+ return rpcError(id, -32601, `Method '${method}' not found`);
69
+ }
70
+ }
71
+
72
+ export function createApp(db: Database.Database): Hono {
73
+ const app = new Hono();
74
+
75
+ app.post("/rpc", async (c) => {
76
+ let body: unknown;
77
+ try {
78
+ body = await c.req.json();
79
+ } catch {
80
+ return c.json(rpcError(null, -32700, "Parse error"), 200);
81
+ }
82
+
83
+ // Batch support
84
+ if (Array.isArray(body)) {
85
+ const responses = body.map((req) => {
86
+ if (!isValidRequest(req)) {
87
+ return rpcError(req?.id ?? null, -32600, "Invalid JSON-RPC request");
88
+ }
89
+ return handleRpc(db, req);
90
+ });
91
+ return c.json(responses, 200);
92
+ }
93
+
94
+ if (!isValidRequest(body)) {
95
+ return c.json(
96
+ rpcError((body as any)?.id ?? null, -32600, "Invalid JSON-RPC request"),
97
+ 200
98
+ );
99
+ }
100
+
101
+ return c.json(handleRpc(db, body), 200);
102
+ });
103
+
104
+ // Health check
105
+ app.get("/health", (c) => c.json({ status: "ok" }));
106
+
107
+ return app;
108
+ }
109
+
110
+ function isValidRequest(obj: unknown): obj is JsonRpcRequest {
111
+ if (typeof obj !== "object" || obj === null) return false;
112
+ const r = obj as Record<string, unknown>;
113
+ return r.jsonrpc === "2.0" && typeof r.method === "string";
114
+ }
115
+
116
+ import net from "net";
117
+
118
+ function isPortInUse(port: number): Promise<boolean> {
119
+ return new Promise((resolve) => {
120
+ const server = net.createServer();
121
+ server.once("error", () => resolve(true));
122
+ server.once("listening", () => {
123
+ server.close();
124
+ resolve(false);
125
+ });
126
+ server.listen(port);
127
+ });
128
+ }
129
+
130
+ export async function startServer(opts: { dbPath?: string; port?: number }): Promise<void> {
131
+ const port = opts.port ?? 3777;
132
+
133
+ if (await isPortInUse(port)) {
134
+ console.error(`Port ${port} is already in use. Another instance may be running.`);
135
+ process.exit(1);
136
+ }
137
+
138
+ const db = openDb(opts.dbPath);
139
+ const app = createApp(db);
140
+
141
+ serve({ fetch: app.fetch, port }, () => {
142
+ console.log(`docs-search server listening on http://localhost:${port}`);
143
+ console.log(`JSON-RPC endpoint: POST http://localhost:${port}/rpc`);
144
+ });
145
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }