diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 74efe73..e8b51f5 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -31,7 +31,7 @@ import { upsertApplicationEnv, deleteApplicationEnv, // Phase 4 ── create/update/delete + domains + databases + services - createPrivateDeployKeyApp, + createPublicApp, updateApplication, deleteApplication, setApplicationDomains, @@ -49,7 +49,7 @@ import { import { query } from '@/lib/db-postgres'; import { getRepo } from '@/lib/gitea'; import { - giteaSshUrl, + giteaHttpsUrl, isDomainUnderWorkspace, slugify, toDomainsString, @@ -85,6 +85,7 @@ export async function GET() { 'apps.get', 'apps.create', 'apps.update', + 'apps.rewire_git', 'apps.delete', 'apps.deploy', 'apps.deployments', @@ -171,6 +172,8 @@ export async function POST(request: Request) { return await toolAppsCreate(principal, params); case 'apps.update': return await toolAppsUpdate(principal, params); + case 'apps.rewire_git': + return await toolAppsRewireGit(principal, params); case 'apps.delete': return await toolAppsDelete(principal, params); case 'apps.domains.list': @@ -449,9 +452,21 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record) { const ws = principal.workspace; - if (!ws.coolify_project_uuid || !ws.coolify_private_key_uuid || !ws.gitea_org) { + if (!ws.coolify_project_uuid || !ws.gitea_org) { return NextResponse.json( - { error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' }, + { error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' }, + { status: 503 } + ); + } + + // We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's + // builtin SSH is on an internal-only port and port 22 hits the host's + // OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and + // the PAT is scoped to the org via team membership. + const botCreds = getWorkspaceBotCredentials(ws); + if (!botCreds) { + return NextResponse.json( + { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, { status: 503 } ); } @@ -484,13 +499,12 @@ async function toolAppsCreate(principal: Principal, params: Record) ); } - const created = await createPrivateDeployKeyApp({ + const created = await createPublicApp({ projectUuid: ws.coolify_project_uuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, - privateKeyUuid: ws.coolify_private_key_uuid, - gitRepository: giteaSshUrl(repoOrg, repoName), + gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token), gitBranch: String(params.branch ?? repo.default_branch ?? 'main'), portsExposes: String(params.ports ?? '3000'), buildPack: (params.buildPack as any) ?? 'nixpacks', @@ -499,6 +513,13 @@ async function toolAppsCreate(principal: Principal, params: Record) isAutoDeployEnabled: true, isForceHttpsEnabled: true, instantDeploy: false, + dockerComposeLocation: params.dockerComposeLocation + ? String(params.dockerComposeLocation) + : undefined, + dockerfileLocation: params.dockerfileLocation + ? String(params.dockerfileLocation) + : undefined, + baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined, }); // Attach envs @@ -543,9 +564,9 @@ async function toolAppsUpdate(principal: Principal, params: Record) await getApplicationInProject(appUuid, projectUuid); const allowed = new Set([ - 'name', 'description', 'git_branch', 'build_pack', 'ports_exposes', + 'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes', 'install_command', 'build_command', 'start_command', - 'base_directory', 'dockerfile_location', + 'base_directory', 'dockerfile_location', 'docker_compose_location', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', ]); const patch: Record = {}; @@ -559,6 +580,70 @@ async function toolAppsUpdate(principal: Principal, params: Record) return NextResponse.json({ result: { ok: true, uuid: appUuid } }); } +/** + * Re-point an app's git_repository at the workspace's canonical + * HTTPS+PAT clone URL. Useful to recover older apps that were created + * with SSH URLs (which don't work on this Gitea topology), or to + * rotate the bot PAT embedded in the URL after a credential cycle. + * The repo name is inferred from the current URL unless `repo` is + * passed explicitly (in `owner/name` form). + */ +async function toolAppsRewireGit(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + + const ws = principal.workspace; + const botCreds = getWorkspaceBotCredentials(ws); + if (!botCreds) { + return NextResponse.json( + { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, + { status: 503 } + ); + } + + const app = await getApplicationInProject(appUuid, projectUuid); + + let repoOrg: string; + let repoName: string; + if (params.repo) { + const parts = String(params.repo).replace(/\.git$/, '').split('/'); + if (parts.length !== 2) { + return NextResponse.json({ error: 'Param "repo" must be "owner/name"' }, { status: 400 }); + } + [repoOrg, repoName] = parts; + } else { + const m = (app.git_repository ?? '').match( + /(?:git@[^:]+:|https?:\/\/(?:[^/]+@)?[^/]+\/)([^/]+)\/([^/.]+)(?:\.git)?$/ + ); + if (!m) { + return NextResponse.json( + { error: 'Could not infer repo from current git_repository; pass repo="owner/name"' }, + { status: 400 } + ); + } + [, repoOrg, repoName] = m; + } + if (repoOrg !== ws.gitea_org) { + return NextResponse.json( + { error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` }, + { status: 403 } + ); + } + + const newUrl = giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token); + await updateApplication(appUuid, { git_repository: newUrl }); + return NextResponse.json({ + result: { + ok: true, + uuid: appUuid, + repo: `${repoOrg}/${repoName}`, + gitUrlScheme: 'https+pat', + }, + }); +} + async function toolAppsDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; diff --git a/lib/coolify.ts b/lib/coolify.ts index 4fac67f..2f2aeab 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -402,7 +402,7 @@ export async function createPrivateDeployKeyApp( export interface CreatePublicAppOpts { projectUuid: string; - gitRepository: string; // https URL + gitRepository: string; // https URL (can embed basic-auth creds for private repos) gitBranch?: string; portsExposes: string; serverUuid?: string; @@ -415,6 +415,13 @@ export interface CreatePublicAppOpts { isAutoDeployEnabled?: boolean; isForceHttpsEnabled?: boolean; instantDeploy?: boolean; + installCommand?: string; + buildCommand?: string; + startCommand?: string; + baseDirectory?: string; + dockerfileLocation?: string; + dockerComposeLocation?: string; + manualWebhookSecretGitea?: string; } export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> { @@ -433,6 +440,13 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true, is_force_https_enabled: opts.isForceHttpsEnabled ?? true, instant_deploy: opts.instantDeploy ?? true, + install_command: opts.installCommand, + build_command: opts.buildCommand, + start_command: opts.startCommand, + base_directory: opts.baseDirectory, + dockerfile_location: opts.dockerfileLocation, + docker_compose_location: opts.dockerComposeLocation, + manual_webhook_secret_gitea: opts.manualWebhookSecretGitea, }); return coolifyFetch('/applications/public', { method: 'POST', diff --git a/lib/gitea.ts b/lib/gitea.ts index 4abd7cf..0fda510 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -155,6 +155,7 @@ export interface GiteaUser { login: string; full_name?: string; email?: string; + active?: boolean; } /** @@ -168,7 +169,7 @@ export async function createUser(opts: { password: string; fullName?: string; }): Promise { - return giteaFetch(`/admin/users`, { + const created = await giteaFetch(`/admin/users`, { method: 'POST', body: JSON.stringify({ username: opts.username, @@ -180,6 +181,26 @@ export async function createUser(opts: { source_id: 0, }), }); + + // Gitea's admin-create endpoint returns users with `active=false` by + // default (a quirk of the admin API — UI-created users skip email + // verification but API-created ones don't). Inactive users fail + // permission checks and cannot clone private repos, so we flip the + // flag immediately via a PATCH. Idempotent: a second call is a noop. + try { + await giteaFetch(`/admin/users/${opts.username}`, { + method: 'PATCH', + body: JSON.stringify({ + source_id: 0, + login_name: opts.username, + active: true, + }), + }); + (created as GiteaUser).active = true; + } catch (err) { + console.warn('[gitea] failed to activate bot user', opts.username, err); + } + return created; } /** diff --git a/lib/naming.ts b/lib/naming.ts index 46034bf..f624f3b 100644 --- a/lib/naming.ts +++ b/lib/naming.ts @@ -60,10 +60,49 @@ export function isDomainUnderWorkspace(fqdn: string, workspaceSlug: string): boo /** * Build a Gitea SSH clone URL for a repo in a workspace's org. - * Matches what Coolify's `private-deploy-key` flow expects. + * + * NOTE: As of 2026-04 this is deprecated for Coolify-driven deploys on + * vibnai.com — Gitea's builtin SSH is bound to host port 22222 which is + * not publicly reachable, and the default port 22 hits Ubuntu's host + * sshd which doesn't know about Gitea keys. Use {@link giteaHttpsUrl} + * instead and embed the workspace bot's PAT. Kept for read-only places + * (e.g. UI display) that want the canonical "clone with SSH" form. */ export function giteaSshUrl(org: string, repo: string, giteaHost = 'git.vibnai.com'): string { return `git@${giteaHost}:${org}/${repo}.git`; } +/** + * Build a Gitea HTTPS clone URL with basic-auth credentials embedded. + * + * https://{username}:{token}@{host}/{org}/{repo}.git + * + * This is what we pass to Coolify's `git_repository` field for every + * Vibn-provisioned app. Works regardless of SSH topology and scopes + * access to whatever the bot user can see. The token is usually the + * per-workspace Gitea bot PAT. + * + * We URL-encode the username and token so PATs with special chars + * (`:`, `/`, `@`, `#`, `?`, etc.) don't break URL parsing. + */ +export function giteaHttpsUrl( + org: string, + repo: string, + username: string, + token: string, + giteaHost = 'git.vibnai.com' +): string { + const u = encodeURIComponent(username); + const t = encodeURIComponent(token); + return `https://${u}:${t}@${giteaHost}/${org}/${repo}.git`; +} + +/** + * Redact the credentials from a Gitea HTTPS URL for safe logging / + * display. Leaves the repo path intact. No-op for non-HTTPS URLs. + */ +export function redactGiteaHttpsUrl(url: string): string { + return url.replace(/^(https?:\/\/)[^@]+@/, '$1***:***@'); +} + export { VIBN_BASE_DOMAIN }; diff --git a/scripts/activate-workspace-bots.ts b/scripts/activate-workspace-bots.ts new file mode 100644 index 0000000..c9beabc --- /dev/null +++ b/scripts/activate-workspace-bots.ts @@ -0,0 +1,73 @@ +/** + * Backfill: activate any Gitea bot users that were created before the + * provisioner started flipping `active: true` automatically. + * + * Safe to re-run — bots that are already active stay active. + * + * Usage: + * npx dotenv-cli -e .env.local -- npx tsx scripts/activate-workspace-bots.ts + */ + +import { query } from '../lib/db-postgres'; + +const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; +const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ''; + +if (!GITEA_API_TOKEN) { + console.error('GITEA_API_TOKEN not set — cannot proceed'); + process.exit(1); +} + +async function gitea(path: string, init?: RequestInit) { + const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${GITEA_API_TOKEN}`, + ...(init?.headers ?? {}), + }, + }); + if (!res.ok && res.status !== 204) { + throw new Error(`gitea ${path} → ${res.status}: ${await res.text()}`); + } + if (res.status === 204) return null; + return res.json(); +} + +async function main() { + const rows = await query<{ slug: string; gitea_bot_username: string }>( + `SELECT slug, gitea_bot_username + FROM vibn_workspaces + WHERE gitea_bot_username IS NOT NULL` + ); + console.log(`Checking ${rows.length} workspace bot(s)…`); + + let touched = 0; + for (const { slug, gitea_bot_username: bot } of rows) { + try { + const u = (await gitea(`/users/${bot}`)) as { active?: boolean } | null; + if (!u) { + console.warn(` [${slug}] bot ${bot} not found in Gitea`); + continue; + } + if (u.active) { + console.log(` [${slug}] ${bot} already active`); + continue; + } + await gitea(`/admin/users/${bot}`, { + method: 'PATCH', + body: JSON.stringify({ source_id: 0, login_name: bot, active: true }), + }); + console.log(` [${slug}] activated ${bot}`); + touched++; + } catch (err) { + console.error(` [${slug}] error:`, err instanceof Error ? err.message : err); + } + } + console.log(`Done — activated ${touched} bot(s).`); +} + +main().catch((err) => { + console.error('FAILED:', err); + process.exit(1); +});