diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index f6688136..0c2528da 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -95,6 +95,19 @@ You are talking to the owner of the "${workspace}" workspace. 2. \`domains_register { domain }\` to buy it (uses workspace billing). 3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically. +## Writing & shipping code (Gitea) +You CAN write code directly — don't tell the user "I can only generate code, you push it." Use these tools to scaffold and edit a project's repo end-to-end: +- \`gitea_repo_create { name }\` — mint a new private repo in the workspace org. +- \`gitea_file_write { repo, path, content, message }\` — commit one file at a time. Call repeatedly to scaffold a project. +- \`gitea_file_read { repo, path }\` — inspect existing code (returns directory listings if path is a folder). +- \`gitea_branches_list\` / \`gitea_branch_create\` — branch for risky edits. +- \`gitea_repos_list\` — discover what already exists before creating anything new. + +End-to-end recipe for "build me X": +1. \`gitea_repo_create { name: 'x' }\`. +2. \`gitea_file_write\` × N — package.json, Dockerfile, src/index.ts, etc. +3. \`apps_create { projectId, repo: 'x', ports, domain }\` — Pathway 1 deploys from the Gitea repo. Coolify auto-redeploys on subsequent file writes. + ## Troubleshooting - Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure. - 502 / "no available server" → app probably has no public domain yet. Check \`apps_get\`; if \`fqdn\` is empty, attach a domain. diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 96c19271..71c78a22 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -83,7 +83,17 @@ import { type CoolifyDatabaseType, } from '@/lib/coolify'; import { query, queryOne } from '@/lib/db-postgres'; -import { getRepo } from '@/lib/gitea'; +import { + getRepo, + createRepo, + giteaPushFile, + giteaReadFile, + giteaListContents, + giteaListBranches, + giteaCreateBranch, + giteaListOrgRepos, + giteaDeleteFile, +} from '@/lib/gitea'; import { giteaHttpsUrl, isDomainUnderWorkspace, @@ -101,7 +111,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.4.8', + version: '2.5.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -155,6 +165,14 @@ export async function GET() { 'storage.describe', 'storage.provision', 'storage.inject_env', + 'gitea.repos.list', + 'gitea.repo.get', + 'gitea.repo.create', + 'gitea.file.read', + 'gitea.file.write', + 'gitea.file.delete', + 'gitea.branches.list', + 'gitea.branch.create', ], }, }, @@ -283,6 +301,23 @@ export async function POST(request: Request) { case 'storage.inject_env': return await toolStorageInjectEnv(principal, params); + case 'gitea.repos.list': + return await toolGiteaReposList(principal); + case 'gitea.repo.get': + return await toolGiteaRepoGet(principal, params); + case 'gitea.repo.create': + return await toolGiteaRepoCreate(principal, params); + case 'gitea.file.read': + return await toolGiteaFileRead(principal, params); + case 'gitea.file.write': + return await toolGiteaFileWrite(principal, params); + case 'gitea.file.delete': + return await toolGiteaFileDelete(principal, params); + case 'gitea.branches.list': + return await toolGiteaBranchesList(principal, params); + case 'gitea.branch.create': + return await toolGiteaBranchCreate(principal, params); + default: return NextResponse.json( { error: `Unknown tool "${action}"` }, @@ -2544,3 +2579,191 @@ async function toolStorageInjectEnv(principal: Principal, params: Record 0 ? ownerParam : org; + if (owner !== org) { + return NextResponse.json( + { error: `owner "${owner}" is outside this workspace's org "${org}"` }, + { status: 403 }, + ); + } + return owner; +} + +async function toolGiteaReposList(principal: Principal) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const repos = await giteaListOrgRepos(org); + return NextResponse.json({ + result: repos.map(r => ({ + name: r.name, + fullName: r.full_name, + defaultBranch: r.default_branch, + cloneUrl: r.clone_url, + htmlUrl: r.html_url, + private: r.private, + })), + }); +} + +async function toolGiteaRepoGet(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const name = String(params.repo ?? params.name ?? '').trim(); + if (!name) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 }); + const r = await getRepo(owner, name); + if (!r) return NextResponse.json({ error: `Repo ${owner}/${name} not found` }, { status: 404 }); + return NextResponse.json({ + result: { + name: r.name, + fullName: r.full_name, + defaultBranch: r.default_branch, + cloneUrl: r.clone_url, + htmlUrl: r.html_url, + private: r.private, + }, + }); +} + +async function toolGiteaRepoCreate(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const name = slugify(String(params.name ?? '').trim()); + if (!name) return NextResponse.json({ error: 'Param "name" is required' }, { status: 400 }); + const repo = await createRepo(name, { + owner: org, + description: params.description ? String(params.description) : undefined, + private: params.private !== false, + auto_init: params.autoInit !== false, + }); + return NextResponse.json({ + result: { + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch, + cloneUrl: repo.clone_url, + htmlUrl: repo.html_url, + }, + }); +} + +async function toolGiteaFileRead(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const repo = String(params.repo ?? '').trim(); + const path = String(params.path ?? '').trim(); + if (!repo || !path) { + return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 }); + } + const ref = params.ref ? String(params.ref) : undefined; + // If path is a directory, list contents instead. The contents API returns + // an array for dirs and an object for files. + try { + const file = await giteaReadFile(owner, repo, path, ref); + return NextResponse.json({ result: { type: 'file', ...file } }); + } catch (err: any) { + // Probably a directory — fall back to listing. + try { + const items = await giteaListContents(owner, repo, path, ref); + return NextResponse.json({ result: { type: 'directory', items } }); + } catch { + return NextResponse.json( + { error: err?.message || `Path ${path} not found in ${owner}/${repo}` }, + { status: 404 }, + ); + } + } +} + +async function toolGiteaFileWrite(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const repo = String(params.repo ?? '').trim(); + const path = String(params.path ?? '').trim(); + const content = typeof params.content === 'string' ? params.content : ''; + const message = String(params.message ?? `Update ${path}`).trim(); + const branch = String(params.branch ?? 'main').trim(); + if (!repo || !path) { + return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 }); + } + await giteaPushFile(owner, repo, path, content, message, branch); + return NextResponse.json({ + result: { ok: true, owner, repo, path, branch, bytes: Buffer.byteLength(content, 'utf-8') }, + }); +} + +async function toolGiteaFileDelete(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const repo = String(params.repo ?? '').trim(); + const path = String(params.path ?? '').trim(); + const branch = String(params.branch ?? 'main').trim(); + const message = String(params.message ?? `Delete ${path}`).trim(); + if (!repo || !path) { + return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 }); + } + // Need the file's current sha to delete; fetch it first. + const file = await giteaReadFile(owner, repo, path, branch).catch(() => null); + if (!file) return NextResponse.json({ error: `${path} not found` }, { status: 404 }); + await giteaDeleteFile(owner, repo, path, file.sha, message, branch); + return NextResponse.json({ result: { ok: true, owner, repo, path, branch } }); +} + +async function toolGiteaBranchesList(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const repo = String(params.repo ?? '').trim(); + if (!repo) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 }); + const branches = await giteaListBranches(owner, repo); + return NextResponse.json({ + result: branches.map(b => ({ name: b.name, sha: b.commit?.id, protected: b.protected })), + }); +} + +async function toolGiteaBranchCreate(principal: Principal, params: Record) { + const org = requireGiteaOrg(principal); + if (org instanceof NextResponse) return org; + const owner = ensureRepoOwnerInOrg(params.owner, org); + if (owner instanceof NextResponse) return owner; + const repo = String(params.repo ?? '').trim(); + const name = String(params.name ?? params.branch ?? '').trim(); + const fromBranch = params.from ? String(params.from) : undefined; + if (!repo || !name) { + return NextResponse.json({ error: 'Params "repo" and "name" are required' }, { status: 400 }); + } + const b = await giteaCreateBranch(owner, repo, name, fromBranch); + return NextResponse.json({ result: { name: b.name, sha: b.commit?.id } }); +} diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts index ad48ae3a..f56a26b9 100644 --- a/lib/ai/vibn-tools.ts +++ b/lib/ai/vibn-tools.ts @@ -518,6 +518,125 @@ Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`, }, }, + // ── Gitea — repos & file CRUD (write code, not just deploy it) ──────────── + // + // All gitea_* tools are scoped to the workspace's Gitea org. The AI can + // create repos, read & write files, and manage branches inside its own + // org but never outside it (enforced by requireGiteaOrg + ensureRepoOwnerInOrg + // in the MCP route). + + { + name: 'gitea_repos_list', + description: + 'List every Gitea repo in the workspace org. Use to discover repos already provisioned for projects.', + parameters: { type: 'OBJECT', properties: {}, required: [] }, + }, + { + name: 'gitea_repo_get', + description: + 'Get metadata for a single Gitea repo (default branch, clone URL, html URL, private flag).', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name (without org prefix).' }, + owner: { type: 'STRING', description: 'Optional org/user. Defaults to the workspace Gitea org.' }, + }, + required: ['repo'], + }, + }, + { + name: 'gitea_repo_create', + description: + 'Create a new Gitea repo inside the workspace org. By default initializes with a README and is private. ' + + 'Use this when scaffolding a new app the AI is going to write code for and then deploy via apps_create({ repo }).', + parameters: { + type: 'OBJECT', + properties: { + name: { type: 'STRING', description: 'Repo name. Will be slugified (lowercase, hyphens).' }, + description: { type: 'STRING', description: 'Optional repo description.' }, + private: { type: 'BOOLEAN', description: 'Whether the repo is private (default true).' }, + autoInit: { type: 'BOOLEAN', description: 'Initialize with README (default true). Set false if writing files immediately yourself.' }, + }, + required: ['name'], + }, + }, + { + name: 'gitea_file_read', + description: + 'Read a single file from a workspace Gitea repo, OR list directory contents if path is a directory. ' + + 'Returns { type: "file", content, sha, size, encoding } or { type: "directory", items: [...] }.', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name.' }, + path: { type: 'STRING', description: 'File or directory path within the repo.' }, + ref: { type: 'STRING', description: 'Branch, tag, or commit SHA (default: repo default branch).' }, + owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' }, + }, + required: ['repo', 'path'], + }, + }, + { + name: 'gitea_file_write', + description: + 'Create or update a single file in a workspace Gitea repo (one commit per call). Use to scaffold or patch code: ' + + 'package.json, Dockerfile, src/server.ts, README.md, etc. Always include a meaningful commit message.', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name.' }, + path: { type: 'STRING', description: 'File path within the repo (e.g. "src/index.ts" or "Dockerfile").' }, + content: { type: 'STRING', description: 'Full new file content as a UTF-8 string.' }, + message: { type: 'STRING', description: 'Commit message. Default: "Update {path}".' }, + branch: { type: 'STRING', description: 'Target branch (default "main"). Branch must already exist.' }, + owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' }, + }, + required: ['repo', 'path', 'content'], + }, + }, + { + name: 'gitea_file_delete', + description: 'Delete a single file from a workspace Gitea repo (one commit). Resolves the file sha automatically.', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name.' }, + path: { type: 'STRING', description: 'File path within the repo.' }, + message: { type: 'STRING', description: 'Commit message. Default: "Delete {path}".' }, + branch: { type: 'STRING', description: 'Target branch (default "main").' }, + owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' }, + }, + required: ['repo', 'path'], + }, + }, + { + name: 'gitea_branches_list', + description: 'List all branches of a workspace Gitea repo with their head SHA.', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name.' }, + owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' }, + }, + required: ['repo'], + }, + }, + { + name: 'gitea_branch_create', + description: + 'Create a new branch in a workspace Gitea repo, branched off an existing one (default = repo default branch).', + parameters: { + type: 'OBJECT', + properties: { + repo: { type: 'STRING', description: 'Repo name.' }, + name: { type: 'STRING', description: 'New branch name.' }, + from: { type: 'STRING', description: 'Existing branch to branch from (default: repo default branch).' }, + owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' }, + }, + required: ['repo', 'name'], + }, + }, + // ── Non-MCP: GitHub & web ───────────────────────────────────────────────── { diff --git a/lib/gitea.ts b/lib/gitea.ts index 0fda5104..9f243a14 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -521,4 +521,95 @@ export async function giteaPushFile( }); } +/** + * Read a file from a Gitea repo. Returns decoded content + sha. + * Throws if path is missing. + */ +export async function giteaReadFile( + owner: string, + repo: string, + path: string, + ref?: string, +): Promise<{ content: string; sha: string; size: number; encoding: string }> { + const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ''; + const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`); + const d = data as any; + const raw = d.content && d.encoding === 'base64' + ? Buffer.from(d.content, 'base64').toString('utf-8') + : (d.content ?? ''); + return { content: raw, sha: d.sha, size: d.size ?? 0, encoding: d.encoding ?? 'utf-8' }; +} + +/** List files/folders at a path inside a Gitea repo. */ +export async function giteaListContents( + owner: string, + repo: string, + path = '', + ref?: string, +): Promise> { + const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ''; + const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`); + const arr = Array.isArray(data) ? data : [data]; + return arr.map((d: any) => ({ + name: d.name, + path: d.path, + type: d.type, + size: d.size ?? 0, + sha: d.sha, + })); +} + +export interface GiteaBranch { + name: string; + commit: { id: string; message?: string }; + protected: boolean; +} + +export async function giteaListBranches(owner: string, repo: string): Promise { + const data = await giteaFetch(`/repos/${owner}/${repo}/branches`); + return Array.isArray(data) ? (data as GiteaBranch[]) : []; +} + +/** Create a new branch from an existing one (defaults to repo's default branch). */ +export async function giteaCreateBranch( + owner: string, + repo: string, + newBranch: string, + fromBranch?: string, +): Promise { + return giteaFetch(`/repos/${owner}/${repo}/branches`, { + method: 'POST', + body: JSON.stringify({ + new_branch_name: newBranch, + ...(fromBranch ? { old_branch_name: fromBranch } : {}), + }), + }) as Promise; +} + +/** List repos owned by an org (or user). */ +export async function giteaListOrgRepos(orgOrUser: string): Promise { + const data = await giteaFetch(`/orgs/${orgOrUser}/repos`).catch(async () => { + // If it's not an org, try the user endpoint. + return giteaFetch(`/users/${orgOrUser}/repos`); + }); + return Array.isArray(data) ? (data as GiteaRepo[]) : []; +} + +/** + * Delete a single file at path. Requires the file's current sha. + */ +export async function giteaDeleteFile( + owner: string, + repo: string, + path: string, + sha: string, + message: string, + branch = 'main', +): Promise { + await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, { + method: 'DELETE', + body: JSON.stringify({ message, sha, branch }), + }); +} + export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;