diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 2eea5e83..9ae0acdc 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -115,6 +115,16 @@ Each Vibn project has a persistent **dev container** (\`vibn-dev\`) running on C - \`fs_glob\` / \`fs_grep\` — find files by pattern, search code by regex (ripgrep, respects .gitignore). - \`fs_list\`, \`fs_delete\` — directory listing, delete. +**Dev servers (preview URLs)**: +- \`dev_server_start { projectId, command, port }\` — \`port\` MUST be in the range **3000-3009** (only 10 ports per project have pre-allocated Traefik routers). Pick 3000 for the primary app; use 3001-3009 only when the user is running multiple servers concurrently (e.g. frontend + API). The returned \`previewUrl\` is the public URL once DNS is wired. +- \`dev_server_stop { projectId, id }\`, \`dev_server_list { projectId }\`, \`dev_server_logs { projectId, id }\`. +- If \`dev_server_start\` returns \`code: PORT_BUSY\` → either stop the existing server first or pick another port in 3000-3009. Don't blindly retry the same port. + +**Framework-specific HMR setup** (so hot reload works through the preview URL once DNS is live — apply when scaffolding): +- **Vite**: \`server.host: '0.0.0.0'\`, \`server.hmr.clientPort: 443\`, \`server.hmr.protocol: 'wss'\`. Vite's default localhost binding will appear to work but break HMR through Traefik. +- **Next dev**: \`next dev -p 3000 -H 0.0.0.0\`. Next handles WSS HMR automatically through proxies. +- **Express / plain Node**: bind \`0.0.0.0\` (we set \`HOST=0.0.0.0\` env automatically, but verify the framework respects it). + **End-to-end recipe for "build me X"**: 1. \`devcontainer_ensure { projectId }\`. 2. \`shell_exec { projectId, command: 'npx create-next-app@latest . --yes' }\` (or whichever scaffold fits — search GitHub first if the user wants an OSS starting point). @@ -127,11 +137,12 @@ Each Vibn project has a persistent **dev container** (\`vibn-dev\`) running on C - The container has no route to internal Vibn services (vibn-postgres, etc.) by design. - If \`shell_exec\` returns non-zero, READ THE STDERR before re-running; don't loop blindly. -## Legacy: Gitea-direct tools (orchestration only) -These still exist for repo-level orchestration but DO NOT use them for iterative file editing — use \`fs_*\` instead: -- \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo_create\` — discover and create repos. -- \`gitea_branches_list\`, \`gitea_branch_create\` — branch management. -- (\`gitea_file_read\` / \`gitea_file_write\` / \`gitea_file_delete\` are deprecated. Prefer \`fs_*\` against the dev container.) +## Gitea repo orchestration (one-time setup) +For creating new repos, branching, and listing what already exists: +- \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo_create\`. +- \`gitea_branches_list\`, \`gitea_branch_create\`. + +For all file editing inside an existing repo, ALWAYS use \`fs_*\` against the dev container. The \`ship\` tool will then push your changes to Gitea in one commit. ## Troubleshooting - Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure. diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 5932c163..ff97d072 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -47,6 +47,10 @@ import { listDevServers, tailDevServerLog, autosaveWorkspace, + PortBusyError, + PortOutOfRangeError, + PREVIEW_BASE_PORT, + PREVIEW_PORT_COUNT, } from '@/lib/dev-container'; import { isPathBDisabled } from '@/lib/feature-flags'; import { @@ -123,7 +127,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.6.0', + version: '2.7.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -3294,9 +3298,11 @@ async function toolDevServerStart(principal: Principal, params: Record 65535) { + if (!command || !Number.isFinite(port)) { return NextResponse.json( - { error: 'Params "command" (string) and "port" (1-65535) are required' }, + { + error: `Params "command" (string) and "port" (number, ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}) are required`, + }, { status: 400 }, ); } @@ -3324,13 +3330,36 @@ async function toolDevServerStart(principal: Principal, params: Record1 server + * - token is a per-project random suffix written at compose-render + * time so URLs aren't enumerable across projects + */ +export const PREVIEW_BASE_PORT = 3000; +export const PREVIEW_PORT_COUNT = 10; + +function projectPreviewToken(projectId: string): string { + // Stable per-project random — derived once and stored in the + // dev-container row so the same subdomains survive container + // restarts. We compute on first compose-render and persist below. + return Buffer.from(projectId).toString('hex').slice(0, 8); +} + +function renderDevCompose(projectSlug: string, projectId: string): string { // Image distribution: we build vibn-dev on the Coolify host once // (see /vibn-dev/setup-on-coolify.sh) and reference it locally. // pull_policy: never tells Docker not to attempt a registry pull. // // Network isolation: vibn-dev sits on its OWN bridge network - // (`vibn-dev-net`) which has no route to vibn-postgres, vibn-frontend, - // or other workspace services. Egress to the public internet still - // works via the bridge's default gateway. This is the cheapest way - // to enforce the §7 "no internal Vibn access" guarantee without - // touching iptables on the host. + // (`vibn-dev-net-${slug}`). On Coolify the Traefik proxy ALSO joins + // this network so it can reach the dev container; vibn-postgres / + // vibn-frontend do not. + // + // Traefik labels: pre-allocated routers for ports 3000..3009. Each + // router uses a distinct subdomain. Routes only "activate" when a + // process is actually listening on the port — Traefik does the + // health check. + const token = projectPreviewToken(projectId); + const traefikLabels: string[] = ['"traefik.enable=true"']; + for (let i = 0; i < PREVIEW_PORT_COUNT; i++) { + const port = PREVIEW_BASE_PORT + i; + const router = `vibn-dev-${projectSlug}-${i}`; + const host = `preview-${i}-${projectSlug}-${token}.${PREVIEW_DOMAIN_BASE_RAW}`; + traefikLabels.push(`"traefik.http.routers.${router}.rule=Host(\\\`${host}\\\`)"`); + traefikLabels.push(`"traefik.http.routers.${router}.entrypoints=https"`); + traefikLabels.push(`"traefik.http.routers.${router}.tls=true"`); + traefikLabels.push(`"traefik.http.routers.${router}.tls.certresolver=letsencrypt"`); + traefikLabels.push(`"traefik.http.services.${router}.loadbalancer.server.port=${port}"`); + traefikLabels.push(`"traefik.http.routers.${router}.service=${router}"`); + } + const labelsBlock = traefikLabels.map(l => ` - ${l}`).join('\n'); + return `services: vibn-dev: image: ${VIBN_DEV_IMAGE} @@ -135,9 +179,14 @@ function renderDevCompose(projectSlug: string): string { - cache:/home/vibn/.cache environment: - VIBN_PROJECT_SLUG=${projectSlug} + - VIBN_PROJECT_ID=${projectId} + - VIBN_PREVIEW_TOKEN=${token} - VIBN_DEV_CONTAINER=1 networks: - vibn-dev-net + - coolify + labels: +${labelsBlock} deploy: resources: limits: @@ -147,12 +196,17 @@ networks: vibn-dev-net: name: vibn-dev-net-${projectSlug} driver: bridge + coolify: + external: true volumes: workspace: cache: `; } +const PREVIEW_DOMAIN_BASE_RAW = + process.env.VIBN_PREVIEW_DOMAIN_BASE ?? 'preview.vibnai.com'; + // ── Provisioning ───────────────────────────────────────────────────── export interface EnsureDevContainerOpts { @@ -214,7 +268,7 @@ export async function ensureDevContainer( projectUuid: coolifyProjectUuid, name: `vibn-dev-${opts.projectSlug}`, description: `AI dev container for project ${opts.projectName ?? opts.projectSlug}`, - composeRaw: renderDevCompose(opts.projectSlug), + composeRaw: renderDevCompose(opts.projectSlug, opts.projectId), instantDeploy: !opts.noStart, }); @@ -432,21 +486,22 @@ export interface DevServerRow { stopped_at: Date | null; } -const PREVIEW_DOMAIN_BASE = - process.env.VIBN_PREVIEW_DOMAIN_BASE ?? 'preview.vibnai.com'; - function randomToken(bytes = 4): string { const buf = Buffer.alloc(bytes); for (let i = 0; i < bytes; i++) buf[i] = Math.floor(Math.random() * 256); return buf.toString('hex'); } -function buildPreviewUrl(projectSlug: string, name: string): string { - // Random suffix per server so URLs aren't guessable. Subdomain is - // --. (kept under 63 chars for DNS). - const safe = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 20); - const sub = `${safe(name)}-${safe(projectSlug)}-${randomToken()}`; - return `https://${sub}.${PREVIEW_DOMAIN_BASE}`; +/** + * Map (projectSlug, port) → preview URL. Must match the Host() rules + * baked into the compose labels by renderDevCompose. Slot index is + * derived from `port - PREVIEW_BASE_PORT`. + */ +function buildPreviewUrl(projectId: string, projectSlug: string, port: number): string | null { + const slot = port - PREVIEW_BASE_PORT; + if (slot < 0 || slot >= PREVIEW_PORT_COUNT) return null; + const token = projectPreviewToken(projectId); + return `https://preview-${slot}-${projectSlug}-${token}.${PREVIEW_DOMAIN_BASE_RAW}`; } export interface StartDevServerOpts { @@ -458,16 +513,95 @@ export interface StartDevServerOpts { workspace: VibnWorkspace; } +export class PortBusyError extends Error { + constructor( + public readonly port: number, + public readonly listenerPid: number | null, + public readonly listenerCmd: string, + ) { + super( + `Port ${port} is already in use by pid ${listenerPid ?? '?'} (${listenerCmd}). ` + + `Stop it first, or pick another port from ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}.`, + ); + this.name = 'PortBusyError'; + } +} + +export class PortOutOfRangeError extends Error { + constructor(public readonly port: number) { + super( + `Port ${port} is outside the preview slot range ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}. ` + + `Pick a port in that range so the preview URL is reachable through Traefik.`, + ); + this.name = 'PortOutOfRangeError'; + } +} + export async function startDevServer(opts: StartDevServerOpts): Promise { await ensureDevServersTable(); + + // 1. Validate slot range — outside this range we couldn't expose + // the preview through Traefik anyway (no router pre-allocated). + if ( + opts.port < PREVIEW_BASE_PORT || + opts.port >= PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT + ) { + throw new PortOutOfRangeError(opts.port); + } + + // 2. Detect listeners on the requested port. We use ss (ships in + // iproute2, default in Ubuntu base) because lsof isn't installed. + // If a vibn-tracked dev server already owns the port, mark its + // row stopped and reuse the slot. If something untracked is + // listening, fail loudly so the AI surfaces a real error to the + // user instead of silently launching a doomed second process. + const portCheck = await execInDevContainer({ + projectId: opts.projectId, + command: + `ss -tlnpH "sport = :${opts.port}" 2>/dev/null | head -1; ` + + // also include any process listening (without name resolution) as a fallback + `lsof -iTCP:${opts.port} -sTCP:LISTEN -n -P 2>/dev/null | tail -n +2 | head -1 || true`, + timeoutMs: 5_000, + }); + const listenerLine = portCheck.stdout.trim(); + if (listenerLine) { + // Try to extract pid from "users:((\"node\",pid=156,fd=...))" or lsof "node 156 vibn ..." + const pidMatch = listenerLine.match(/pid=(\d+)/) || listenerLine.match(/^\S+\s+(\d+)/); + const listenerPid = pidMatch ? parseInt(pidMatch[1], 10) : null; + + const tracked = await queryOne( + `SELECT * FROM fs_dev_servers + WHERE project_id = $1 AND port = $2 AND state IN ('starting','running') + ORDER BY started_at DESC LIMIT 1`, + [opts.projectId, opts.port], + ); + if (tracked && tracked.pid && listenerPid && tracked.pid === listenerPid) { + // Same project owns the port via a tracked row. Reap it cleanly + // so the new start has a clean slot. AI's expected behaviour is + // "I want THIS command on THIS port" — so we honour the + // most-recent-write-wins intent rather than throwing. + await execInDevContainer({ + projectId: opts.projectId, + command: `kill ${tracked.pid} 2>/dev/null || true; sleep 0.3`, + timeoutMs: 5_000, + }); + await query( + `UPDATE fs_dev_servers SET state='stopped', stopped_at=now() WHERE id = $1`, + [tracked.id], + ); + } else { + throw new PortBusyError(opts.port, listenerPid, listenerLine.slice(0, 200)); + } + } + + // 3. Launch. const id = `ds_${randomToken(6)}`; const name = opts.name ?? `port-${opts.port}`; - const previewUrl = buildPreviewUrl(opts.projectSlug, name); + const previewUrl = + buildPreviewUrl(opts.projectId, opts.projectSlug, opts.port) ?? + `https://localhost-only:${opts.port}`; const logFile = `/var/log/vibn-dev/${id}.log`; - // nohup the command, capture PID. We pin the listening interface to - // 0.0.0.0 by injecting HOST=0.0.0.0 (handles Vite/Next/Express); we - // also export PORT so frameworks that read it pick it up. const launch = `mkdir -p /var/log/vibn-dev && ` + `cd /workspace && ` +