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:
16
.env.example
16
.env.example
@@ -8,3 +8,19 @@ DOCKER_SOCKET_PATH=/var/run/docker.sock
|
|||||||
|
|
||||||
# Name of the shared container network joining the manager and every installed MCP container
|
# Name of the shared container network joining the manager and every installed MCP container
|
||||||
MCP_NETWORK_NAME=mcp-net
|
MCP_NETWORK_NAME=mcp-net
|
||||||
|
|
||||||
|
# Where the manager stores its SQLite DB, cloned repos and build logs
|
||||||
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
# Public base URL used to build the authenticated MCP links shown in the UI, e.g. https://mcp.example.com
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Password for the single admin/operator account that can use the panel
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Random secret used to sign the admin session cookie
|
||||||
|
SESSION_SECRET=change-me-too
|
||||||
|
|
||||||
|
# Random string (min 32 chars) used to derive the AES-256-GCM key that encrypts
|
||||||
|
# installation env vars and bearer tokens at rest
|
||||||
|
MASTER_KEY=change-me-to-a-random-string-of-at-least-32-characters
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
|||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM ghcr.io/railwayapp/nixpacks:1.41.0 AS nixpacks
|
||||||
|
FROM docker:27-cli AS dockercli
|
||||||
|
|
||||||
|
FROM node:20-slim AS build
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/server/package.json apps/server/package.json
|
||||||
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/server apps/server
|
||||||
|
COPY apps/web apps/web
|
||||||
|
RUN npm run build --workspace apps/web
|
||||||
|
RUN npm run build --workspace apps/server
|
||||||
|
|
||||||
|
FROM node:20-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=nixpacks /usr/bin/nixpacks /usr/local/bin/nixpacks
|
||||||
|
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||||
|
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/server/package.json apps/server/package.json
|
||||||
|
RUN npm ci --omit=dev --workspace apps/server
|
||||||
|
|
||||||
|
COPY --from=build /repo/apps/server/dist apps/server/dist
|
||||||
|
COPY --from=build /repo/apps/web/dist apps/web/dist
|
||||||
|
|
||||||
|
WORKDIR /repo/apps/server
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# remote-mcp-manager
|
||||||
|
|
||||||
|
Self-hosted manager for MCP servers: install one from a GitHub repo URL, configure
|
||||||
|
its env vars, build it with [Nixpacks](https://nixpacks.com) (no Dockerfile needed
|
||||||
|
in the source repo), run it in a container, and get back an authenticated
|
||||||
|
`https://<your-domain>/mcp/<slug>` URL guarded by a bearer token.
|
||||||
|
|
||||||
|
## Quick start (production, Docker)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # fill in ADMIN_PASSWORD, SESSION_SECRET, MASTER_KEY, PUBLIC_BASE_URL
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Put your own reverse proxy (nginx, etc.) in front of port 3000 for TLS and your public domain.
|
||||||
|
|
||||||
|
## Local development (Podman)
|
||||||
|
|
||||||
|
Requires `nixpacks` and a `docker` CLI pointed at your Podman socket (e.g. via the
|
||||||
|
`podman-docker` package) on your PATH.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
DOCKER_SOCKET_PATH=/run/user/$(id -u)/podman/podman.sock \
|
||||||
|
DATA_DIR=./data MASTER_KEY=... SESSION_SECRET=... ADMIN_PASSWORD=... \
|
||||||
|
npm run dev:server
|
||||||
|
```
|
||||||
|
|
||||||
|
Because rootless Podman isolates the host network namespace from the container bridge,
|
||||||
|
the manager itself must run as a container attached to `mcp-net` to reach the MCP
|
||||||
|
containers it manages — running it directly on the host only works for routes that
|
||||||
|
don't need to reach a sibling container (e.g. `/health`, `/api/auth`).
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Nixpacks needs a dependency manifest (`requirements.txt`/`pyproject.toml` for Python,
|
||||||
|
`package.json` for Node) in the installed repo. Repos without one will fail to build;
|
||||||
|
the full build log is always visible in the UI to diagnose this.
|
||||||
|
- No per-container resource limits, image garbage collection, or multi-user support yet.
|
||||||
|
- Builds run one at a time.
|
||||||
@@ -8,13 +8,17 @@
|
|||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "11.10.0",
|
||||||
"dockerode": "4.0.9",
|
"dockerode": "4.0.9",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
|
"express-session": "1.18.1",
|
||||||
"http-proxy": "1.18.1"
|
"http-proxy": "1.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/dockerode": "3.3.42",
|
"@types/dockerode": "3.3.42",
|
||||||
"@types/express": "4.17.23",
|
"@types/express": "4.17.23",
|
||||||
|
"@types/express-session": "1.18.2",
|
||||||
"@types/http-proxy": "1.17.16",
|
"@types/http-proxy": "1.17.16",
|
||||||
"@types/node": "24.9.1",
|
"@types/node": "24.9.1",
|
||||||
"tsx": "4.20.6",
|
"tsx": "4.20.6",
|
||||||
|
|||||||
50
apps/server/src/api/auth.ts
Normal file
50
apps/server/src/api/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
function passwordsMatch(expected: string, provided: string): boolean {
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(provided);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let failedAttempts = 0;
|
||||||
|
let lockoutUntil = 0;
|
||||||
|
|
||||||
|
authRouter.post("/login", (req, res) => {
|
||||||
|
if (Date.now() < lockoutUntil) {
|
||||||
|
res.status(429).json({ error: "too many attempts, try again later" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
if (!config.adminPassword) {
|
||||||
|
res.status(500).json({ error: "ADMIN_PASSWORD is not configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof password !== "string" || !passwordsMatch(config.adminPassword, password)) {
|
||||||
|
failedAttempts += 1;
|
||||||
|
if (failedAttempts >= 5) {
|
||||||
|
lockoutUntil = Date.now() + 60_000;
|
||||||
|
failedAttempts = 0;
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: "invalid password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failedAttempts = 0;
|
||||||
|
req.session.authenticated = true;
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/logout", (req, res) => {
|
||||||
|
req.session.destroy(() => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/session", (req, res) => {
|
||||||
|
res.json({ authenticated: Boolean(req.session.authenticated) });
|
||||||
|
});
|
||||||
136
apps/server/src/api/installations.ts
Normal file
136
apps/server/src/api/installations.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
import {
|
||||||
|
type Installation,
|
||||||
|
listInstallations,
|
||||||
|
getInstallationById,
|
||||||
|
deleteInstallation,
|
||||||
|
updateInstallation,
|
||||||
|
} from "../db/installations.js";
|
||||||
|
import {
|
||||||
|
createDraftInstallation,
|
||||||
|
configureInstallation,
|
||||||
|
buildInstallation,
|
||||||
|
deployInstallation,
|
||||||
|
redeployInstallation,
|
||||||
|
removeInstallationContainer,
|
||||||
|
} from "../service/orchestrator.js";
|
||||||
|
import { buildLogPath } from "../build/nixpacks.js";
|
||||||
|
import { generateBearerToken } from "../crypto/secrets.js";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
export const installationsRouter = Router();
|
||||||
|
|
||||||
|
// Not masked: this API sits behind single-operator session auth (requireAuth), and the
|
||||||
|
// operator is the same person who owns these secrets — editing them requires seeing them.
|
||||||
|
function toPublicJson(installation: Installation) {
|
||||||
|
return {
|
||||||
|
...installation,
|
||||||
|
publicUrl: `${config.publicBaseUrl}/mcp/${installation.slug}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
installationsRouter.get("/", (_req, res) => {
|
||||||
|
res.json(listInstallations().map(toPublicJson));
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.get("/:id", (req, res) => {
|
||||||
|
const installation = getInstallationById(req.params.id);
|
||||||
|
if (!installation) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(toPublicJson(installation));
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.post("/", async (req, res) => {
|
||||||
|
const { name, repoUrl, gitRef } = req.body ?? {};
|
||||||
|
if (!name || !repoUrl) {
|
||||||
|
res.status(400).json({ error: "name and repoUrl are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { installation, detectedEnvKeys } = await createDraftInstallation({ name, repoUrl, gitRef });
|
||||||
|
res.status(201).json({ installation: toPublicJson(installation), detectedEnvKeys });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.patch("/:id/config", (req, res) => {
|
||||||
|
const { env, portEnvVar, hostEnvVar, httpPath, internalPort } = req.body ?? {};
|
||||||
|
try {
|
||||||
|
const installation = configureInstallation(req.params.id, { env, portEnvVar, hostEnvVar, httpPath, internalPort });
|
||||||
|
res.json(toPublicJson(installation));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(404).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.post("/:id/build", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const installation = await buildInstallation(req.params.id);
|
||||||
|
res.json(toPublicJson(installation));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.post("/:id/deploy", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const installation = await deployInstallation(req.params.id);
|
||||||
|
res.json(toPublicJson(installation));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.post("/:id/redeploy", async (req, res) => {
|
||||||
|
const rebuild = Boolean(req.body?.rebuild);
|
||||||
|
try {
|
||||||
|
const installation = await redeployInstallation(req.params.id, { rebuild });
|
||||||
|
res.json(toPublicJson(installation));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.get("/:id/logs", (req, res) => {
|
||||||
|
const installation = getInstallationById(req.params.id);
|
||||||
|
if (!installation) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = `${config.logsDir}/${installation.slug}`;
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
res.json({ log: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(dir).sort();
|
||||||
|
const latest = files.at(-1);
|
||||||
|
if (!latest) {
|
||||||
|
res.json({ log: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ log: fs.readFileSync(buildLogPath(installation.slug, latest.replace(/\.log$/, "")), "utf8") });
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.delete("/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
await removeInstallationContainer(req.params.id);
|
||||||
|
deleteInstallation(req.params.id);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installationsRouter.post("/:id/regenerate-token", (req, res) => {
|
||||||
|
try {
|
||||||
|
const installation = updateInstallation(req.params.id, { bearerToken: generateBearerToken() });
|
||||||
|
res.json({ bearerToken: installation.bearerToken });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(404).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
46
apps/server/src/build/git.ts
Normal file
46
apps/server/src/build/git.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], cwd?: string): Promise<{ code: number; output: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { cwd });
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (d) => (output += d.toString()));
|
||||||
|
child.stderr.on("data", (d) => (output += d.toString()));
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => resolve({ code: code ?? 1, output }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repoDir(slug: string): string {
|
||||||
|
return path.join(config.reposDir, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneOrUpdateRepo(slug: string, repoUrl: string, gitRef?: string | null): Promise<string> {
|
||||||
|
const dir = repoDir(slug);
|
||||||
|
fs.mkdirSync(config.reposDir, { recursive: true });
|
||||||
|
|
||||||
|
if (fs.existsSync(path.join(dir, ".git"))) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = await run("git", ["clone", "--depth", "1", ...(gitRef ? ["--branch", gitRef] : []), repoUrl, dir]);
|
||||||
|
if (clone.code !== 0) {
|
||||||
|
throw new Error(`git clone failed: ${clone.output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEnvExampleKeys(dir: string): string[] {
|
||||||
|
const file = path.join(dir, ".env.example");
|
||||||
|
if (!fs.existsSync(file)) return [];
|
||||||
|
const content = fs.readFileSync(file, "utf8");
|
||||||
|
return content
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith("#") && line.includes("="))
|
||||||
|
.map((line) => line.split("=")[0]!.trim());
|
||||||
|
}
|
||||||
36
apps/server/src/build/nixpacks.ts
Normal file
36
apps/server/src/build/nixpacks.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
export function buildLogPath(slug: string, buildId: string): string {
|
||||||
|
return path.join(config.logsDir, slug, `${buildId}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildResult {
|
||||||
|
success: boolean;
|
||||||
|
logPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const buildId = Date.now().toString();
|
||||||
|
const logPath = buildLogPath(slug, buildId);
|
||||||
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
|
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||||
|
|
||||||
|
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag]);
|
||||||
|
child.stdout.pipe(logStream, { end: false });
|
||||||
|
child.stderr.pipe(logStream, { end: false });
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
logStream.end();
|
||||||
|
resolve({ success: code === 0, logPath });
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
logStream.write(`\nfailed to spawn nixpacks: ${err.message}\n`);
|
||||||
|
logStream.end();
|
||||||
|
resolve({ success: false, logPath });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR ?? path.resolve(process.cwd(), "../../data");
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
port: Number(process.env.PORT ?? 3000),
|
port: Number(process.env.PORT ?? 3000),
|
||||||
// Docker in prod; a rootless Podman socket (e.g. /run/user/<uid>/podman/podman.sock) in local dev.
|
// Docker in prod; a rootless Podman socket (e.g. /run/user/<uid>/podman/podman.sock) in local dev.
|
||||||
dockerSocketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
|
dockerSocketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
|
||||||
mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net",
|
mcpNetworkName: process.env.MCP_NETWORK_NAME ?? "mcp-net",
|
||||||
|
dataDir,
|
||||||
|
dbPath: process.env.DB_PATH ?? path.join(dataDir, "db.sqlite"),
|
||||||
|
reposDir: path.join(dataDir, "repos"),
|
||||||
|
logsDir: path.join(dataDir, "logs"),
|
||||||
|
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD,
|
||||||
|
sessionSecret: process.env.SESSION_SECRET,
|
||||||
};
|
};
|
||||||
|
|||||||
31
apps/server/src/crypto/secrets.ts
Normal file
31
apps/server/src/crypto/secrets.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
function masterKey(): Buffer {
|
||||||
|
const raw = process.env.MASTER_KEY;
|
||||||
|
if (!raw || raw.length < 32) {
|
||||||
|
throw new Error("MASTER_KEY env var must be set to a string of at least 32 characters");
|
||||||
|
}
|
||||||
|
return crypto.createHash("sha256").update(raw).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(plaintext: string): string {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", masterKey(), iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(encoded: string): string {
|
||||||
|
const raw = Buffer.from(encoded, "base64");
|
||||||
|
const iv = raw.subarray(0, 12);
|
||||||
|
const authTag = raw.subarray(12, 28);
|
||||||
|
const ciphertext = raw.subarray(28);
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", masterKey(), iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBearerToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
32
apps/server/src/db/index.ts
Normal file
32
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||||
|
|
||||||
|
export const db = new Database(config.dbPath);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS installations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
repo_url TEXT NOT NULL,
|
||||||
|
git_ref TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
status_detail TEXT,
|
||||||
|
image_tag TEXT,
|
||||||
|
container_id TEXT,
|
||||||
|
container_name TEXT NOT NULL,
|
||||||
|
internal_port INTEGER NOT NULL DEFAULT 8080,
|
||||||
|
http_path TEXT NOT NULL DEFAULT '/mcp',
|
||||||
|
port_env_var TEXT NOT NULL DEFAULT 'PORT',
|
||||||
|
host_env_var TEXT NOT NULL DEFAULT 'HOST',
|
||||||
|
bearer_token_encrypted TEXT,
|
||||||
|
env_json_encrypted TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
192
apps/server/src/db/installations.ts
Normal file
192
apps/server/src/db/installations.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { db } from "./index.js";
|
||||||
|
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
||||||
|
|
||||||
|
export type InstallationStatus =
|
||||||
|
| "draft"
|
||||||
|
| "cloning"
|
||||||
|
| "building"
|
||||||
|
| "built"
|
||||||
|
| "starting"
|
||||||
|
| "running"
|
||||||
|
| "stopped"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface Installation {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
gitRef: string | null;
|
||||||
|
status: InstallationStatus;
|
||||||
|
statusDetail: string | null;
|
||||||
|
imageTag: string | null;
|
||||||
|
containerId: string | null;
|
||||||
|
containerName: string;
|
||||||
|
internalPort: number;
|
||||||
|
httpPath: string;
|
||||||
|
portEnvVar: string;
|
||||||
|
hostEnvVar: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
bearerToken: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
repo_url: string;
|
||||||
|
git_ref: string | null;
|
||||||
|
status: InstallationStatus;
|
||||||
|
status_detail: string | null;
|
||||||
|
image_tag: string | null;
|
||||||
|
container_id: string | null;
|
||||||
|
container_name: string;
|
||||||
|
internal_port: number;
|
||||||
|
http_path: string;
|
||||||
|
port_env_var: string;
|
||||||
|
host_env_var: string;
|
||||||
|
bearer_token_encrypted: string | null;
|
||||||
|
env_json_encrypted: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToInstallation(row: Row): Installation {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
repoUrl: row.repo_url,
|
||||||
|
gitRef: row.git_ref,
|
||||||
|
status: row.status,
|
||||||
|
statusDetail: row.status_detail,
|
||||||
|
imageTag: row.image_tag,
|
||||||
|
containerId: row.container_id,
|
||||||
|
containerName: row.container_name,
|
||||||
|
internalPort: row.internal_port,
|
||||||
|
httpPath: row.http_path,
|
||||||
|
portEnvVar: row.port_env_var,
|
||||||
|
hostEnvVar: row.host_env_var,
|
||||||
|
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
||||||
|
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInstallation(input: { name: string; slug: string; repoUrl: string; gitRef?: string }): Installation {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO installations
|
||||||
|
(id, slug, name, repo_url, git_ref, status, container_name, created_at, updated_at)
|
||||||
|
VALUES (@id, @slug, @name, @repoUrl, @gitRef, 'draft', @containerName, @now, @now)`,
|
||||||
|
).run({
|
||||||
|
id,
|
||||||
|
slug: input.slug,
|
||||||
|
name: input.name,
|
||||||
|
repoUrl: input.repoUrl,
|
||||||
|
gitRef: input.gitRef ?? null,
|
||||||
|
containerName: `mcp-${input.slug}`,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
return getInstallationById(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallationById(id: string): Installation | undefined {
|
||||||
|
const row = db.prepare("SELECT * FROM installations WHERE id = ?").get(id) as Row | undefined;
|
||||||
|
return row ? rowToInstallation(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallationBySlug(slug: string): Installation | undefined {
|
||||||
|
const row = db.prepare("SELECT * FROM installations WHERE slug = ?").get(slug) as Row | undefined;
|
||||||
|
return row ? rowToInstallation(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listInstallations(): Installation[] {
|
||||||
|
const rows = db.prepare("SELECT * FROM installations ORDER BY created_at DESC").all() as Row[];
|
||||||
|
return rows.map(rowToInstallation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallationUpdate {
|
||||||
|
status?: InstallationStatus;
|
||||||
|
statusDetail?: string | null;
|
||||||
|
imageTag?: string | null;
|
||||||
|
containerId?: string | null;
|
||||||
|
internalPort?: number;
|
||||||
|
httpPath?: string;
|
||||||
|
portEnvVar?: string;
|
||||||
|
hostEnvVar?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
bearerToken?: string | null;
|
||||||
|
gitRef?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInstallation(id: string, patch: InstallationUpdate): Installation {
|
||||||
|
const current = getInstallationById(id);
|
||||||
|
if (!current) throw new Error(`installation ${id} not found`);
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
status: patch.status ?? current.status,
|
||||||
|
statusDetail: patch.statusDetail !== undefined ? patch.statusDetail : current.statusDetail,
|
||||||
|
imageTag: patch.imageTag !== undefined ? patch.imageTag : current.imageTag,
|
||||||
|
containerId: patch.containerId !== undefined ? patch.containerId : current.containerId,
|
||||||
|
internalPort: patch.internalPort ?? current.internalPort,
|
||||||
|
httpPath: patch.httpPath ?? current.httpPath,
|
||||||
|
portEnvVar: patch.portEnvVar ?? current.portEnvVar,
|
||||||
|
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
||||||
|
env: patch.env ?? current.env,
|
||||||
|
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
|
||||||
|
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE installations SET
|
||||||
|
status = @status,
|
||||||
|
status_detail = @statusDetail,
|
||||||
|
image_tag = @imageTag,
|
||||||
|
container_id = @containerId,
|
||||||
|
internal_port = @internalPort,
|
||||||
|
http_path = @httpPath,
|
||||||
|
port_env_var = @portEnvVar,
|
||||||
|
host_env_var = @hostEnvVar,
|
||||||
|
env_json_encrypted = @envJsonEncrypted,
|
||||||
|
bearer_token_encrypted = @bearerTokenEncrypted,
|
||||||
|
git_ref = @gitRef,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = @id`,
|
||||||
|
).run({
|
||||||
|
id,
|
||||||
|
status: next.status,
|
||||||
|
statusDetail: next.statusDetail,
|
||||||
|
imageTag: next.imageTag,
|
||||||
|
containerId: next.containerId,
|
||||||
|
internalPort: next.internalPort,
|
||||||
|
httpPath: next.httpPath,
|
||||||
|
portEnvVar: next.portEnvVar,
|
||||||
|
hostEnvVar: next.hostEnvVar,
|
||||||
|
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
||||||
|
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
||||||
|
gitRef: next.gitRef,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return getInstallationById(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInstallation(id: string): void {
|
||||||
|
db.prepare("DELETE FROM installations WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import net from "node:net";
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import { config } from "../config/env.js";
|
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 });
|
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> {
|
export async function removeContainerIfExists(containerName: string): Promise<void> {
|
||||||
await ensureNetwork(config.mcpNetworkName);
|
|
||||||
|
|
||||||
const existing = docker.getContainer(installation.containerName);
|
|
||||||
try {
|
try {
|
||||||
await existing.remove({ force: true });
|
await docker.getContainer(containerName).remove({ force: true });
|
||||||
} catch {
|
} 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 = {
|
const env = {
|
||||||
...installation.env,
|
...installation.env,
|
||||||
@@ -38,6 +43,11 @@ export async function runInstallationContainer(installation: Installation): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
await container.start();
|
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> {
|
export async function getContainerIp(containerName: string): Promise<string> {
|
||||||
@@ -49,3 +59,35 @@ export async function getContainerIp(containerName: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
return network.IPAddress;
|
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})` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,49 @@
|
|||||||
|
import path from "node:path";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import session from "express-session";
|
||||||
import { config } from "./config/env.js";
|
import { config } from "./config/env.js";
|
||||||
|
import "./db/index.js";
|
||||||
import { handleMcpProxyRequest } from "./proxy/mcpProxy.js";
|
import { handleMcpProxyRequest } from "./proxy/mcpProxy.js";
|
||||||
|
import { authRouter } from "./api/auth.js";
|
||||||
|
import { installationsRouter } from "./api/installations.js";
|
||||||
|
import { requireAuth } from "./middleware/requireAuth.js";
|
||||||
|
|
||||||
|
if (!config.sessionSecret) {
|
||||||
|
throw new Error("SESSION_SECRET env var must be set");
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: config.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 1000 * 60 * 60 * 12,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// Bearer-token-gated MCP reverse proxy, public (auth handled per-installation).
|
||||||
app.all("/mcp/:slug", handleMcpProxyRequest);
|
app.all("/mcp/:slug", handleMcpProxyRequest);
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/installations", requireAuth, installationsRouter);
|
||||||
|
|
||||||
|
const webDist = path.resolve(import.meta.dirname, "../../web/dist");
|
||||||
|
app.use(express.static(webDist));
|
||||||
|
app.get(/^\/(?!api|mcp|health).*/, (_req, res) => {
|
||||||
|
res.sendFile(path.join(webDist, "index.html"), (err) => {
|
||||||
|
if (err) res.status(404).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`remote-mcp-manager listening on http://localhost:${config.port}`);
|
console.log(`remote-mcp-manager listening on http://localhost:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
export interface Installation {
|
|
||||||
slug: string;
|
|
||||||
imageTag: string;
|
|
||||||
containerName: string;
|
|
||||||
internalPort: number;
|
|
||||||
httpPath: string;
|
|
||||||
portEnvVar: string;
|
|
||||||
hostEnvVar: string;
|
|
||||||
env: Record<string, string>;
|
|
||||||
bearerToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slice 2: hardcoded registry, replaced by SQLite-backed storage in slice 3.
|
|
||||||
export const installations: Installation[] = [
|
|
||||||
{
|
|
||||||
slug: "social",
|
|
||||||
imageTag: "localhost/mcpmgr-test-social:latest",
|
|
||||||
containerName: "mcp-social",
|
|
||||||
internalPort: 8080,
|
|
||||||
httpPath: "/mcp",
|
|
||||||
portEnvVar: "PORT",
|
|
||||||
hostEnvVar: "HOST",
|
|
||||||
env: {},
|
|
||||||
bearerToken: "dev-slice2-token",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function findInstallationBySlug(slug: string): Installation | undefined {
|
|
||||||
return installations.find((i) => i.slug === slug);
|
|
||||||
}
|
|
||||||
9
apps/server/src/middleware/requireAuth.ts
Normal file
9
apps/server/src/middleware/requireAuth.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (req.session.authenticated) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: "authentication required" });
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import httpProxy from "http-proxy";
|
import httpProxy from "http-proxy";
|
||||||
import { findInstallationBySlug } from "../installations.js";
|
import { getInstallationBySlug } from "../db/installations.js";
|
||||||
import { getContainerIp } from "../docker/containers.js";
|
import { getContainerIp } from "../docker/containers.js";
|
||||||
|
|
||||||
const proxy = httpProxy.createProxyServer();
|
const proxy = httpProxy.createProxyServer();
|
||||||
proxy.on("error", (err, _req, res) => {
|
proxy.on("error", (err, _req, res) => {
|
||||||
if (!res.headersSent) {
|
if (!(res as Response).headersSent) {
|
||||||
(res as Response).status(502).json({ error: "proxy error", detail: err.message });
|
(res as Response).status(502).json({ error: "proxy error", detail: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -20,9 +20,9 @@ function tokensMatch(expected: string, provided: string): boolean {
|
|||||||
|
|
||||||
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const installation = findInstallationBySlug(slug);
|
const installation = getInstallationBySlug(slug);
|
||||||
if (!installation) {
|
if (!installation || installation.status !== "running" || !installation.bearerToken) {
|
||||||
res.status(404).json({ error: "unknown installation" });
|
res.status(404).json({ error: "unknown or unavailable installation" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
apps/server/src/service/orchestrator.ts
Normal file
101
apps/server/src/service/orchestrator.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
type Installation,
|
||||||
|
createInstallation,
|
||||||
|
updateInstallation,
|
||||||
|
getInstallationById,
|
||||||
|
getInstallationBySlug,
|
||||||
|
slugify,
|
||||||
|
} from "../db/installations.js";
|
||||||
|
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
|
||||||
|
import { runNixpacksBuild } from "../build/nixpacks.js";
|
||||||
|
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
|
||||||
|
import { generateBearerToken } from "../crypto/secrets.js";
|
||||||
|
|
||||||
|
export interface DraftResult {
|
||||||
|
installation: Installation;
|
||||||
|
detectedEnvKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDraftInstallation(input: {
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
gitRef?: string;
|
||||||
|
}): Promise<DraftResult> {
|
||||||
|
const slug = slugify(input.name);
|
||||||
|
if (getInstallationBySlug(slug)) {
|
||||||
|
throw new Error(`an installation with slug "${slug}" already exists`);
|
||||||
|
}
|
||||||
|
const installation = createInstallation({ name: input.name, slug, repoUrl: input.repoUrl, gitRef: input.gitRef });
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateInstallation(installation.id, { status: "cloning" });
|
||||||
|
const dir = await cloneOrUpdateRepo(slug, input.repoUrl, input.gitRef);
|
||||||
|
const detectedEnvKeys = readEnvExampleKeys(dir);
|
||||||
|
const updated = updateInstallation(installation.id, { status: "draft", statusDetail: null });
|
||||||
|
return { installation: updated, detectedEnvKeys };
|
||||||
|
} catch (err) {
|
||||||
|
const updated = updateInstallation(installation.id, { status: "error", statusDetail: (err as Error).message });
|
||||||
|
return { installation: updated, detectedEnvKeys: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureInstallation(
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
portEnvVar?: string;
|
||||||
|
hostEnvVar?: string;
|
||||||
|
httpPath?: string;
|
||||||
|
internalPort?: number;
|
||||||
|
},
|
||||||
|
): Installation {
|
||||||
|
return updateInstallation(id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildInstallation(id: string): Promise<Installation> {
|
||||||
|
const installation = getInstallationById(id);
|
||||||
|
if (!installation) throw new Error("installation not found");
|
||||||
|
|
||||||
|
updateInstallation(id, { status: "building", statusDetail: null });
|
||||||
|
const imageTag = `mcpmgr/${installation.slug}:latest`;
|
||||||
|
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return updateInstallation(id, {
|
||||||
|
status: "error",
|
||||||
|
statusDetail: `build failed, see log at ${result.logPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deployInstallation(id: string): Promise<Installation> {
|
||||||
|
let installation = getInstallationById(id);
|
||||||
|
if (!installation) throw new Error("installation not found");
|
||||||
|
|
||||||
|
updateInstallation(id, { status: "starting", statusDetail: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runInstallationContainer(installation);
|
||||||
|
await waitForContainerHealth(installation);
|
||||||
|
|
||||||
|
const bearerToken = installation.bearerToken ?? generateBearerToken();
|
||||||
|
return updateInstallation(id, { status: "running", statusDetail: null, bearerToken });
|
||||||
|
} catch (err) {
|
||||||
|
return updateInstallation(id, { status: "error", statusDetail: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeployInstallation(id: string, opts: { rebuild?: boolean } = {}): Promise<Installation> {
|
||||||
|
if (opts.rebuild) {
|
||||||
|
await buildInstallation(id);
|
||||||
|
}
|
||||||
|
return deployInstallation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeInstallationContainer(id: string): Promise<void> {
|
||||||
|
const installation = getInstallationById(id);
|
||||||
|
if (!installation) return;
|
||||||
|
await stopInstallationContainer(installation.containerName);
|
||||||
|
}
|
||||||
7
apps/server/src/types/session.d.ts
vendored
Normal file
7
apps/server/src/types/session.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import "express-session";
|
||||||
|
|
||||||
|
declare module "express-session" {
|
||||||
|
interface SessionData {
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal 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
22
apps/web/package.json
Normal 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
28
apps/web/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/src/api/client.ts
Normal file
60
apps/web/src/api/client.ts
Normal 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
9
apps/web/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
65
apps/web/src/pages/InstallWizard.tsx
Normal file
65
apps/web/src/pages/InstallWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
apps/web/src/pages/InstallationDetail.tsx
Normal file
211
apps/web/src/pages/InstallationDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/src/pages/InstallationList.tsx
Normal file
61
apps/web/src/pages/InstallationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/web/src/pages/Login.tsx
Normal file
42
apps/web/src/pages/Login.tsx
Normal 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
14
apps/web/tsconfig.json
Normal 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
11
apps/web/vite.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
manager:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
DOCKER_SOCKET_PATH: /var/run/docker.sock
|
||||||
|
MCP_NETWORK_NAME: mcp-net
|
||||||
|
DATA_DIR: /repo/data
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
MASTER_KEY: ${MASTER_KEY}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/repo/data
|
||||||
|
networks:
|
||||||
|
- mcp-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mcp-net:
|
||||||
|
name: mcp-net
|
||||||
2109
package-lock.json
generated
2109
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user