/** * theia-exec.ts * * Bridge between vibn-agent-runner and the Theia container. * * Both containers share the `coolify` Docker network, so the agent runner * can reach Theia at http://theia-code-os:3000 internally. * * Theia runs a lightweight sync-server.js on port 3001 that: * - POST /sync { repo: "mark/sportsy" } → git pull (or clone) inside Theia * - GET /health * * After the agent auto-commits to Gitea, we call this endpoint so the * files appear immediately in Theia when the user clicks "Open in Theia →". * * theiaExec() is also provided as a future path for running commands inside * Theia via docker exec once the docker socket is mounted. * * Environment variables: * THEIA_SYNC_URL — full URL for the sync server (default: http://theia-code-os:3001) * THEIA_CONTAINER — container name for docker exec (optional, Phase 2.1) */ import { execSync } from 'child_process'; const THEIA_SYNC_URL = (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001').replace(/\/$/, ''); // ── Sync endpoint (HTTP — no docker socket needed) ──────────────────────────── /** * Tell Theia to git pull (or clone) the given repo so "Open in Theia" shows * the agent's latest committed files. * * @param giteaRepo e.g. "mark/sportsy" */ export async function syncRepoToTheia(giteaRepo: string): Promise<{ ok: boolean; action?: string; error?: string }> { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30_000); const res = await fetch(`${THEIA_SYNC_URL}/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: giteaRepo }), signal: controller.signal, }).finally(() => clearTimeout(timeout)); const data = await res.json() as { ok: boolean; action?: string; error?: string }; return data; } catch (err: any) { return { ok: false, error: err.message }; } } /** * Check if the Theia sync server is reachable. */ export async function isTheiaSyncAvailable(): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); const res = await fetch(`${THEIA_SYNC_URL}/health`, { signal: controller.signal }) .finally(() => clearTimeout(timeout)); return res.ok; } catch { return false; } } // ── Docker exec (Phase 2.1 — requires docker socket mount) ─────────────────── // Falls back gracefully when docker is not available. let _theiaContainer: string | null | undefined = undefined; export function getTheiaContainer(): string | null { if (_theiaContainer !== undefined) return _theiaContainer; if (process.env.THEIA_CONTAINER) { _theiaContainer = process.env.THEIA_CONTAINER.trim(); return _theiaContainer; } try { const byLabel = execSync( `docker ps --filter "label=coolify.applicationId=b8wswk0ggcg88k0c8osk4c4c" --format "{{.Names}}" 2>/dev/null`, { timeout: 5000 } ).toString().trim(); if (byLabel) { _theiaContainer = byLabel.split('\n')[0].trim(); return _theiaContainer; } } catch { /* docker not available */ } try { const byName = execSync( `docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`, { timeout: 5000 } ).toString().trim(); if (byName) { _theiaContainer = byName.split('\n')[0].trim(); return _theiaContainer; } } catch { /* ignore */ } _theiaContainer = null; return null; } export function isTheiaAvailable(): boolean { return getTheiaContainer() !== null; } /** * Run a command inside the Theia container via docker exec. * Requires /var/run/docker.sock to be mounted in the agent runner. */ export async function theiaExec( command: string, workdir?: string ): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> { const container = getTheiaContainer(); if (!container) { return { exitCode: 1, stdout: '', stderr: '', error: 'Theia container not reachable via docker exec' }; } const cwd = workdir ?? '/home/project'; const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`; return new Promise(resolve => { const { execFile } = require('child_process') as typeof import('child_process'); execFile( 'docker', ['exec', container, 'bash', '-c', wrapped], { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ exitCode: typeof err?.code === 'number' ? err.code : 0, stdout: stdout.trim(), stderr: stderr.trim(), error: err ? err.message : undefined, }); } ); }); } /** * Copy local directory into the Theia container via docker cp. * Requires /var/run/docker.sock to be mounted. */ export async function syncToTheia( localDir: string, theiaPath: string | null ): Promise<{ ok: boolean; error?: string }> { const container = getTheiaContainer(); if (!container) { return { ok: false, error: 'Theia container not reachable via docker' }; } const destDir = theiaPath ? `/home/project/${theiaPath}` : '/home/project'; try { execSync(`docker exec ${container} mkdir -p ${JSON.stringify(destDir)}`, { timeout: 10_000 }); execSync(`docker cp "${localDir}/." "${container}:${destDir}/"`, { timeout: 60_000 }); return { ok: true }; } catch (err: any) { return { ok: false, error: err.message }; } }