UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

335 lines • 15 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { OLLAMA_CONTAINER_NAME, OLLAMA_HOST, OLLAMA_IMAGE, OLLAMA_PORT, QDRANT_API_KEY, QDRANT_CONTAINER_NAME, QDRANT_GRPC_PORT, QDRANT_HOST, QDRANT_IMAGE, QDRANT_MODE, QDRANT_PORT, QDRANT_URL, } from "../constants.js"; import { ollamaDisplayOrigin } from "./embedding-config.js"; import { logger } from "./logger.js"; const execFileAsync = promisify(execFile); async function run(cmd, args, timeoutMs = 120_000) { try { return await execFileAsync(cmd, args, { timeout: timeoutMs }); } catch (err) { const e = err; if (e.killed) { throw new Error(`Command timed out after ${timeoutMs / 1000}s: ${cmd} ${args.join(" ")}`); } throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${e.stderr || e.message}`); } } // ── Docker ──────────────────────────────────────────────────────────────── /** Check if Docker CLI is reachable */ export async function isDockerAvailable() { try { await run("docker", ["info"]); return true; } catch { return false; } } // ── Qdrant ──────────────────────────────────────────────────────────────── /** Check if the Qdrant Docker image is pulled */ export async function isQdrantImagePresent() { try { const { stdout } = await run("docker", ["images", "--format", "{{.Repository}}:{{.Tag}}", QDRANT_IMAGE]); return stdout.includes("qdrant/qdrant"); } catch { return false; } } /** Pull the Qdrant Docker image (may take several minutes on first launch) */ export async function pullQdrantImage(onProgress) { onProgress?.("Downloading Qdrant Docker image (first time only, may take a few minutes)..."); logger.info("Pulling Qdrant Docker image", { image: QDRANT_IMAGE }); try { await run("docker", ["pull", QDRANT_IMAGE], 10 * 60_000); // 10 minute timeout for image pull } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes("timed out")) { throw new Error(`Qdrant image download timed out after 10 minutes. Check your network connection and try again.`); } throw new Error(`Failed to download Qdrant image. Check your network connection and available disk space.\nDetails: ${msg}`); } } /** Check if the Qdrant container is running */ export async function isQdrantRunning() { try { const { stdout } = await run("docker", [ "ps", "--filter", `name=${QDRANT_CONTAINER_NAME}`, "--format", "{{.Names}}", ]); return stdout.trim().includes(QDRANT_CONTAINER_NAME); } catch { return false; } } /** Check if the Qdrant container exists (even if stopped) */ async function doesQdrantContainerExist() { try { const { stdout } = await run("docker", [ "ps", "-a", "--filter", `name=${QDRANT_CONTAINER_NAME}`, "--format", "{{.Names}}", ]); return stdout.trim().includes(QDRANT_CONTAINER_NAME); } catch { return false; } } /** Start the Qdrant container, creating it if necessary */ export async function startQdrant(onProgress) { if (await isQdrantRunning()) return; if (await doesQdrantContainerExist()) { onProgress?.("Starting existing Qdrant container..."); logger.info("Starting existing Qdrant container"); await run("docker", ["start", QDRANT_CONTAINER_NAME]); onProgress?.("Waiting for Qdrant to be ready..."); await waitForService(`http://${QDRANT_HOST}:${QDRANT_PORT}/healthz`, "Qdrant"); return; } onProgress?.("Creating and starting Qdrant container..."); logger.info("Creating and starting Qdrant container", { port: QDRANT_PORT }); try { await run("docker", [ "run", "-d", "--name", QDRANT_CONTAINER_NAME, "-p", `${QDRANT_PORT}:6333`, "-p", `${QDRANT_GRPC_PORT}:6334`, "-v", "socraticode_qdrant_data:/qdrant/storage", "--restart", "unless-stopped", QDRANT_IMAGE, ]); } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to start Qdrant container. Is port ${QDRANT_PORT} already in use?\nDetails: ${msg}`); } onProgress?.("Waiting for Qdrant to be ready..."); await waitForService(`http://${QDRANT_HOST}:${QDRANT_PORT}/healthz`, "Qdrant"); } // ── Readiness cache ────────────────────────────────────────────────────── const READINESS_TTL_MS = 60_000; // 60 seconds let qdrantReadyAt = 0; /** Reset the cached readiness state (e.g. after infra error or explicit restart) */ export function resetQdrantReadinessCache() { qdrantReadyAt = 0; } /** Ensure Qdrant is ready. * - managed mode: ensure Docker is available, image is pulled, container is running. * - external mode: validate config and health-check the remote endpoint. */ export async function ensureQdrantReady(onProgress) { // Fast path: recently verified as ready if (Date.now() - qdrantReadyAt < READINESS_TTL_MS) { return { started: false, pulled: false }; } if (QDRANT_MODE === "external") { return ensureExternalQdrantReady(onProgress); } // ── managed mode: Docker-managed container ──────────────────────────── let pulled = false; let started = false; if (!(await isDockerAvailable())) { throw new Error("Docker is not available. Please install Docker Desktop (https://www.docker.com/products/docker-desktop/) and make sure it is running."); } onProgress?.("Checking Qdrant vector database..."); if (!(await isQdrantImagePresent())) { await pullQdrantImage(onProgress); pulled = true; } if (!(await isQdrantRunning())) { await startQdrant(onProgress); started = true; } qdrantReadyAt = Date.now(); return { started, pulled }; } /** Validate config and health-check an externally managed Qdrant instance. */ async function ensureExternalQdrantReady(onProgress) { // Require at least one of: QDRANT_URL or a non-default QDRANT_HOST if (!QDRANT_URL && QDRANT_HOST === "localhost") { throw new Error("QDRANT_MODE=external requires QDRANT_URL (e.g. https://xyz.aws.cloud.qdrant.io:6333) " + "or QDRANT_HOST to be set to your remote Qdrant server's hostname."); } const baseUrl = QDRANT_URL ? QDRANT_URL.replace(/\/$/, "") : `http://${QDRANT_HOST}:${QDRANT_PORT}`; const healthUrl = `${baseUrl}/healthz`; // If an api-key is configured, refuse to send it over a non-TLS connection. // Localhost loopback URLs are accepted because some users run authenticated // Qdrant locally during development. The URL is parsed (rather than checked // with startsWith) so that hostnames like "http://localhost.evil.com" are // not mistaken for loopback. Placed before the try/catch so the specific // error is not masked by the generic "Cannot reach" message below. if (QDRANT_API_KEY) { let parsed = null; try { parsed = new URL(baseUrl); } catch { /* fall through: the unparseable URL will fail the reachability check */ } const isHttps = parsed?.protocol === "https:"; const isLoopback = parsed ? ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) : false; if (parsed && !isHttps && !isLoopback) { throw new Error(`QDRANT_API_KEY is set but ${baseUrl} is not HTTPS. ` + "Refusing to send the API key over a non-TLS connection. " + "Use https://... for remote or cloud Qdrant (a localhost URL is also accepted for local development)."); } } onProgress?.(`Checking external Qdrant at ${baseUrl}...`); logger.info("Checking external Qdrant", { url: baseUrl }); // Qdrant Cloud requires authentication on every endpoint, including /healthz // (returns 403 without an api-key header). Locally run Qdrant typically does // not, so the header is sent only when QDRANT_API_KEY is configured. const init = QDRANT_API_KEY ? { headers: { "api-key": QDRANT_API_KEY } } : undefined; try { // 5 retries × 1 s — fast fail for misconfiguration, brief grace for transient flakiness await waitForService(healthUrl, "Qdrant", 5, 1000, init); } catch { throw new Error(`Cannot reach external Qdrant at ${baseUrl}.\n` + "Verify that QDRANT_URL (or QDRANT_HOST/QDRANT_PORT) and QDRANT_API_KEY (if required) are correct and the server is reachable."); } qdrantReadyAt = Date.now(); return { started: false, pulled: false }; } // ── Ollama ──────────────────────────────────────────────────────────────── /** Check if the Ollama Docker image is pulled */ export async function isOllamaImagePresent() { try { const { stdout } = await run("docker", ["images", "--format", "{{.Repository}}:{{.Tag}}", OLLAMA_IMAGE]); return stdout.includes("ollama/ollama"); } catch { return false; } } /** Pull the Ollama Docker image (may take several minutes on first launch) */ export async function pullOllamaImage(onProgress) { onProgress?.("Downloading Ollama Docker image (first time only, may take a few minutes)..."); logger.info("Pulling Ollama Docker image", { image: OLLAMA_IMAGE }); try { await run("docker", ["pull", OLLAMA_IMAGE], 10 * 60_000); // 10 minute timeout for image pull } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes("timed out")) { throw new Error(`Ollama image download timed out after 10 minutes. Check your network connection and try again.`); } throw new Error(`Failed to download Ollama image. Check your network connection and available disk space.\nDetails: ${msg}`); } } /** Check if the Ollama container is running */ export async function isOllamaRunning() { try { const { stdout } = await run("docker", [ "ps", "--filter", `name=${OLLAMA_CONTAINER_NAME}`, "--format", "{{.Names}}", ]); return stdout.trim().includes(OLLAMA_CONTAINER_NAME); } catch { return false; } } /** Check if the Ollama container exists (even if stopped) */ async function doesOllamaContainerExist() { try { const { stdout } = await run("docker", [ "ps", "-a", "--filter", `name=${OLLAMA_CONTAINER_NAME}`, "--format", "{{.Names}}", ]); return stdout.trim().includes(OLLAMA_CONTAINER_NAME); } catch { return false; } } /** Start the Ollama container, creating it if necessary */ export async function startOllama(onProgress) { if (await isOllamaRunning()) return; if (await doesOllamaContainerExist()) { onProgress?.("Starting existing Ollama container..."); logger.info("Starting existing Ollama container"); await run("docker", ["start", OLLAMA_CONTAINER_NAME]); onProgress?.("Waiting for Ollama to be ready..."); await waitForService(`${OLLAMA_HOST}/api/tags`, "Ollama"); return; } onProgress?.("Creating and starting Ollama container..."); logger.info("Creating and starting Ollama container", { port: OLLAMA_PORT }); try { await run("docker", [ "run", "-d", "--name", OLLAMA_CONTAINER_NAME, "-p", `${OLLAMA_PORT}:11434`, "-v", "socraticode_ollama_data:/root/.ollama", "--restart", "unless-stopped", OLLAMA_IMAGE, ]); } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to start Ollama container. Is port ${OLLAMA_PORT} already in use?\nDetails: ${msg}`); } onProgress?.("Waiting for Ollama to be ready..."); await waitForService(`${OLLAMA_HOST}/api/tags`, "Ollama"); } let ollamaContainerReadyAt = 0; /** Reset the cached readiness state for Ollama container */ export function resetOllamaContainerReadinessCache() { ollamaContainerReadyAt = 0; } /** Ensure Docker is available, Ollama image is pulled, and Ollama container is running */ export async function ensureOllamaContainerReady(onProgress) { // Fast path: recently verified as ready if (Date.now() - ollamaContainerReadyAt < READINESS_TTL_MS) { return { started: false, pulled: false }; } let pulled = false; let started = false; if (!(await isDockerAvailable())) { throw new Error("Docker is not available. Please install Docker Desktop (https://www.docker.com/products/docker-desktop/) and make sure it is running."); } onProgress?.("Checking Ollama embedding service..."); if (!(await isOllamaImagePresent())) { await pullOllamaImage(onProgress); pulled = true; } if (!(await isOllamaRunning())) { await startOllama(onProgress); started = true; } ollamaContainerReadyAt = Date.now(); return { started, pulled }; } // ── Shared ──────────────────────────────────────────────────────────────── /** Wait for an HTTP service to respond with 200. * * `init` is forwarded to fetch(), allowing callers to attach headers (e.g. an * `api-key` header for Qdrant Cloud, whose /healthz endpoint requires auth). */ async function waitForService(url, serviceName, retries = 30, delayMs = 1000, init) { logger.info(`Waiting for ${serviceName} to be ready`, { url: serviceName === "Ollama" ? ollamaDisplayOrigin(url) : url }); for (let i = 0; i < retries; i++) { try { const resp = await fetch(url, init); if (resp.ok) { logger.info(`${serviceName} is ready`); return; } } catch { // not ready yet } await new Promise((r) => setTimeout(r, delayMs)); } throw new Error(`${serviceName} did not become ready in time (${retries * delayMs / 1000}s)`); } //# sourceMappingURL=docker.js.map