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

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>remote-mcp-manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
apps/web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@remote-mcp-manager/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.1"
},
"devDependencies": {
"@types/react": "18.3.26",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.9.3",
"vite": "5.4.20"
}
}

28
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { api } from "./api/client";
import { Login } from "./pages/Login";
import { InstallationList } from "./pages/InstallationList";
import { InstallationDetail } from "./pages/InstallationDetail";
export function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
api.session().then((s) => setAuthenticated(s.authenticated));
}, []);
if (authenticated === null) return null;
if (!authenticated) return <Login onLoggedIn={() => setAuthenticated(true)} />;
return (
<BrowserRouter>
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16, fontFamily: "sans-serif" }}>
<Routes>
<Route path="/" element={<InstallationList />} />
<Route path="/installations/:id" element={<InstallationDetail />} />
</Routes>
</div>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,60 @@
export interface Installation {
id: string;
slug: string;
name: string;
repoUrl: string;
gitRef: string | null;
status: "draft" | "cloning" | "building" | "built" | "starting" | "running" | "stopped" | "error";
statusDetail: string | null;
imageTag: string | null;
containerName: string;
internalPort: number;
httpPath: string;
portEnvVar: string;
hostEnvVar: string;
env: Record<string, string>;
bearerToken: string | null;
publicUrl: string;
createdAt: string;
updatedAt: string;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `request failed with ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
session: () => request<{ authenticated: boolean }>("/api/auth/session"),
login: (password: string) => request<{ ok: true }>("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }),
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
listInstallations: () => request<Installation[]>("/api/installations"),
getInstallation: (id: string) => request<Installation>(`/api/installations/${id}`),
createInstallation: (input: { name: string; repoUrl: string; gitRef?: string }) =>
request<{ installation: Installation; detectedEnvKeys: string[] }>("/api/installations", {
method: "POST",
body: JSON.stringify(input),
}),
configureInstallation: (
id: string,
patch: { env?: Record<string, string>; portEnvVar?: string; hostEnvVar?: string; httpPath?: string },
) => request<Installation>(`/api/installations/${id}/config`, { method: "PATCH", body: JSON.stringify(patch) }),
build: (id: string) => request<Installation>(`/api/installations/${id}/build`, { method: "POST" }),
deploy: (id: string) => request<Installation>(`/api/installations/${id}/deploy`, { method: "POST" }),
redeploy: (id: string, rebuild: boolean) =>
request<Installation>(`/api/installations/${id}/redeploy`, { method: "POST", body: JSON.stringify({ rebuild }) }),
regenerateToken: (id: string) =>
request<{ bearerToken: string }>(`/api/installations/${id}/regenerate-token`, { method: "POST" }),
remove: (id: string) => request<void>(`/api/installations/${id}`, { method: "DELETE" }),
logs: (id: string) => request<{ log: string }>(`/api/installations/${id}/logs`),
};

9
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,65 @@
import { useState } from "react";
import { api } from "../api/client";
export function InstallWizard({
onCreated,
onCancel,
}: {
onCreated: (id: string, detectedEnvKeys: string[]) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [gitRef, setGitRef] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const { installation, detectedEnvKeys } = await api.createInstallation({ name, repoUrl, gitRef: gitRef || undefined });
onCreated(installation.id, detectedEnvKeys);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ border: "1px solid #ccc", padding: 16, marginBottom: 16 }}>
<h2>New installation</h2>
<form onSubmit={submit}>
<label>
Name
<input value={name} onChange={(e) => setName(e.target.value)} required style={{ display: "block", width: "100%" }} />
</label>
<label>
Repo URL
<input
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
required
style={{ display: "block", width: "100%" }}
/>
</label>
<label>
Git ref (optional)
<input value={gitRef} onChange={(e) => setGitRef(e.target.value)} style={{ display: "block", width: "100%" }} />
</label>
<div style={{ marginTop: 8 }}>
<button type="submit" disabled={loading}>
{loading ? "Cloning…" : "Clone & continue"}
</button>{" "}
<button type="button" onClick={onCancel}>
Cancel
</button>
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api, type Installation } from "../api/client";
export function InstallationDetail() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const [installation, setInstallation] = useState<Installation | null>(null);
const [envRows, setEnvRows] = useState<[string, string][]>([]);
const [portEnvVar, setPortEnvVar] = useState("PORT");
const [hostEnvVar, setHostEnvVar] = useState("HOST");
const [httpPath, setHttpPath] = useState("/mcp");
const [log, setLog] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
if (!id) return;
const data = await api.getInstallation(id);
setInstallation(data);
setPortEnvVar(data.portEnvVar);
setHostEnvVar(data.hostEnvVar);
setHttpPath(data.httpPath);
const detectedEnvKeys = (location.state as { detectedEnvKeys?: string[] } | null)?.detectedEnvKeys ?? [];
const keys = new Set([...Object.keys(data.env), ...detectedEnvKeys]);
setEnvRows([...keys].map((k) => [k, data.env[k] ?? ""]));
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function withBusy(label: string, fn: () => Promise<void>) {
setBusy(label);
setError(null);
try {
await fn();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
if (!installation) return <p>Loading…</p>;
return (
<div>
<button onClick={() => navigate("/")}>← Back</button>
<h1>{installation.name}</h1>
<p>
Status: <strong>{installation.status}</strong>
{installation.statusDetail && <span style={{ color: "crimson" }}> — {installation.statusDetail}</span>}
</p>
{installation.status === "running" && installation.bearerToken && (
<div style={{ border: "1px solid #ccc", padding: 12, marginBottom: 16 }}>
<strong>Public URL:</strong> <code>{installation.publicUrl}</code>
<br />
<strong>Bearer token:</strong> <code>{installation.bearerToken}</code>{" "}
<button
onClick={() =>
withBusy("regen", async () => {
await api.regenerateToken(installation.id);
await refresh();
})
}
>
Regenerate token
</button>
</div>
)}
<h2>Configuration</h2>
<table>
<tbody>
{envRows.map(([k, v], idx) => (
<tr key={idx}>
<td>
<input
value={k}
onChange={(e) => {
const next = [...envRows];
next[idx] = [e.target.value, v];
setEnvRows(next);
}}
/>
</td>
<td>
<input
value={v}
onChange={(e) => {
const next = [...envRows];
next[idx] = [k, e.target.value];
setEnvRows(next);
}}
/>
</td>
<td>
<button onClick={() => setEnvRows(envRows.filter((_, i) => i !== idx))}>remove</button>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={() => setEnvRows([...envRows, ["", ""]])}>Add env var</button>
<div style={{ marginTop: 12 }}>
<label>
Port env var name <input value={portEnvVar} onChange={(e) => setPortEnvVar(e.target.value)} />
</label>
<label style={{ marginLeft: 12 }}>
Host env var name <input value={hostEnvVar} onChange={(e) => setHostEnvVar(e.target.value)} />
</label>
<label style={{ marginLeft: 12 }}>
HTTP path <input value={httpPath} onChange={(e) => setHttpPath(e.target.value)} />
</label>
</div>
<div style={{ marginTop: 12 }}>
<button
disabled={busy !== null}
onClick={() =>
withBusy("config", async () => {
const env = Object.fromEntries(envRows.filter(([k]) => k.trim() !== ""));
await api.configureInstallation(installation.id, { env, portEnvVar, hostEnvVar, httpPath });
await refresh();
})
}
>
Save configuration
</button>
</div>
<h2>Actions</h2>
<button
disabled={busy !== null}
onClick={() =>
withBusy("build", async () => {
await api.build(installation.id);
await refresh();
})
}
>
{busy === "build" ? "Building…" : "Build"}
</button>{" "}
<button
disabled={busy !== null || installation.status === "cloning" || installation.status === "building" || installation.status === "starting"}
onClick={() =>
withBusy("deploy", async () => {
await api.deploy(installation.id);
await refresh();
})
}
>
{busy === "deploy" ? "Deploying…" : "Deploy"}
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("redeploy", async () => {
await api.redeploy(installation.id, false);
await refresh();
})
}
>
Redeploy (env only)
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("rebuild", async () => {
await api.redeploy(installation.id, true);
await refresh();
})
}
>
Rebuild & redeploy
</button>{" "}
<button
disabled={busy !== null}
onClick={() =>
withBusy("delete", async () => {
await api.remove(installation.id);
navigate("/");
})
}
>
Delete
</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<h2>Build log</h2>
<button
onClick={() =>
withBusy("logs", async () => {
const { log } = await api.logs(installation.id);
setLog(log);
})
}
>
Refresh log
</button>
<pre style={{ background: "#111", color: "#0f0", padding: 12, maxHeight: 300, overflow: "auto" }}>{log}</pre>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api, type Installation } from "../api/client";
import { InstallWizard } from "./InstallWizard";
export function InstallationList() {
const [installations, setInstallations] = useState<Installation[]>([]);
const [showWizard, setShowWizard] = useState(false);
const navigate = useNavigate();
async function refresh() {
setInstallations(await api.listInstallations());
}
useEffect(() => {
refresh();
}, []);
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1>Installations</h1>
<button onClick={() => setShowWizard((v) => !v)}>{showWizard ? "Close" : "New installation"}</button>
</div>
{showWizard && (
<InstallWizard
onCancel={() => setShowWizard(false)}
onCreated={(id, detectedEnvKeys) => {
setShowWizard(false);
navigate(`/installations/${id}`, { state: { detectedEnvKeys } });
}}
/>
)}
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th align="left">Name</th>
<th align="left">Slug</th>
<th align="left">Status</th>
<th align="left">Repo</th>
</tr>
</thead>
<tbody>
{installations.map((i) => (
<tr key={i.id} style={{ borderTop: "1px solid #eee" }}>
<td>
<Link to={`/installations/${i.id}`}>{i.name}</Link>
</td>
<td>{i.slug}</td>
<td>{i.status}</td>
<td>{i.repoUrl}</td>
</tr>
))}
</tbody>
</table>
{installations.length === 0 && !showWizard && <p>No installations yet.</p>}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import { api } from "../api/client";
export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
await api.login(password);
onLoggedIn();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 320, margin: "10vh auto" }}>
<h1>remote-mcp-manager</h1>
<form onSubmit={submit}>
<input
type="password"
placeholder="Admin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
style={{ width: "100%", padding: 8, marginBottom: 8 }}
/>
<button type="submit" disabled={loading} style={{ width: "100%", padding: 8 }}>
{loading ? "Signing in…" : "Sign in"}
</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
</div>
);
}

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

11
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});