feat: SQLite-backed installations, REST API, admin auth and web UI

Replaces the slice-2 hardcoded installation with a full install/deploy
pipeline:

- SQLite persistence (better-sqlite3) with a single `installations`
  table and a draft -> cloning -> building -> built -> starting ->
  running/error/stopped state machine; env vars and bearer tokens are
  encrypted at rest (AES-256-GCM, key derived from MASTER_KEY).
- Build/run orchestration: git clone (+ .env.example key detection),
  `nixpacks build` as a logged subprocess, dockerode container
  run/stop with forced host/port env vars and a TCP-connect health
  check before flipping an installation to "running".
- REST API (installations CRUD, build/deploy/redeploy, logs, token
  regeneration) behind single-operator session-cookie auth with a
  basic login lockout.
- Vite+React admin SPA: login, installation list, install wizard, and
  a detail page to configure env vars, trigger build/deploy/redeploy,
  view the build log, and copy the authenticated /mcp/<slug> URL.
- Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and
  docker-compose.yml for production deployment.

Validated end-to-end against a real cloned repo: draft creation with
env-key detection, build, the error path on a failed health check, and
delete, all through the live API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:42:06 +02:00
parent 309e6aedc3
commit b8f5d8253f
33 changed files with 3502 additions and 42 deletions

View File

@@ -1,6 +1,7 @@
import net from "node:net";
import Docker from "dockerode";
import { config } from "../config/env.js";
import type { Installation } from "../installations.js";
import type { Installation } from "../db/installations.js";
const docker = new Docker({ socketPath: config.dockerSocketPath });
@@ -11,15 +12,19 @@ export async function ensureNetwork(name: string): Promise<void> {
}
}
export async function runInstallationContainer(installation: Installation): Promise<void> {
await ensureNetwork(config.mcpNetworkName);
const existing = docker.getContainer(installation.containerName);
export async function removeContainerIfExists(containerName: string): Promise<void> {
try {
await existing.remove({ force: true });
await docker.getContainer(containerName).remove({ force: true });
} catch {
// container didn't exist yet, nothing to clean up
// didn't exist, nothing to clean up
}
}
export async function runInstallationContainer(installation: Installation): Promise<string> {
if (!installation.imageTag) throw new Error("installation has no built image yet");
await ensureNetwork(config.mcpNetworkName);
await removeContainerIfExists(installation.containerName);
const env = {
...installation.env,
@@ -38,6 +43,11 @@ export async function runInstallationContainer(installation: Installation): Prom
});
await container.start();
return container.id;
}
export async function stopInstallationContainer(containerName: string): Promise<void> {
await removeContainerIfExists(containerName);
}
export async function getContainerIp(containerName: string): Promise<string> {
@@ -49,3 +59,35 @@ export async function getContainerIp(containerName: string): Promise<string> {
}
return network.IPAddress;
}
export async function waitForContainerHealth(
installation: Installation,
timeoutMs = 20_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | undefined;
while (Date.now() < deadline) {
try {
const ip = await getContainerIp(installation.containerName);
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host: ip, port: installation.internalPort, timeout: 1000 });
socket.once("connect", () => {
socket.end();
resolve();
});
socket.once("error", reject);
socket.once("timeout", () => reject(new Error("connect timeout")));
});
return;
} catch (err) {
lastError = err as Error;
await new Promise((r) => setTimeout(r, 1000));
}
}
throw new Error(
`container did not start listening on ${installation.portEnvVar}=${installation.internalPort} within ${timeoutMs}ms` +
(lastError ? ` (last error: ${lastError.message})` : ""),
);
}