/** * GET /api/workspaces/[slug]/bootstrap.sh * * One-shot installer. Intended usage inside a repo: * * curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \ * https://vibnai.com/api/workspaces//bootstrap.sh | sh * * Writes three files into the cwd: * - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents) * - .cursor/mcp.json (registers /api/mcp as an MCP server) * - .env.local (appends VIBN_* envs; never overwrites) * * Auth: caller MUST already have a `vibn_sk_...` token. We embed the * same token in the generated mcp.json so Cursor agents can re-use it. * Session auth works too but then nothing is embedded (the user gets * placeholder strings to fill in themselves). */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com'; export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); if (principal instanceof NextResponse) return principal; const ws = principal.workspace; const tokenFromHeader = extractBearer(request); // For API-key callers we can safely echo the token they sent us // back into the generated files. For session callers we emit a // placeholder — we don't want to re-issue long-lived tokens from // a cookie-authenticated browser request. const embedToken = principal.source === 'api_key' && tokenFromHeader ? tokenFromHeader : ''; const script = buildScript({ slug: ws.slug, giteaOrg: ws.gitea_org ?? '(unprovisioned)', coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)', appBase: APP_BASE, token: embedToken, }); return new NextResponse(script, { status: 200, headers: { 'Content-Type': 'text/x-shellscript; charset=utf-8', 'Cache-Control': 'no-store', }, }); } function extractBearer(request: Request): string | null { const a = request.headers.get('authorization'); if (!a) return null; const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim()); return m?.[1] ?? null; } function buildScript(opts: { slug: string; giteaOrg: string; coolifyProjectUuid: string; appBase: string; token: string; }): string { const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts; // Build the file bodies in TS so we can shell-escape them cleanly // using base64. The script itself does no string interpolation on // these payloads — it just decodes and writes. const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase }); const mcp = JSON.stringify( { mcpServers: { [`vibn-${slug}`]: { url: `${appBase}/api/mcp`, headers: { Authorization: `Bearer ${token}` }, }, }, }, null, 2 ); const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`; const b64Rule = Buffer.from(rule, 'utf8').toString('base64'); const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64'); const b64Env = Buffer.from(env, 'utf8').toString('base64'); return `#!/usr/bin/env sh # Vibn workspace bootstrap — generated ${new Date().toISOString()} # Workspace: ${slug} # # Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json, # and appends VIBN_* env vars to .env.local (never overwrites). set -eu mkdir -p .cursor/rules echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc echo " wrote .cursor/rules/vibn-workspace.mdc" echo "${b64Mcp}" | base64 -d > .cursor/mcp.json echo " wrote .cursor/mcp.json" if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then echo " .env.local already has VIBN_* — skipping env append" else printf '\\n# Vibn workspace ${slug}\\n' >> .env.local echo "${b64Env}" | base64 -d >> .env.local echo " appended VIBN_* to .env.local" fi if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then echo '.env.local' >> .gitignore echo " added .env.local to .gitignore" fi echo "" echo "Vibn workspace '${slug}' is wired up." echo "Restart Cursor to pick up the new MCP server." `; } function buildCursorRule(opts: { slug: string; giteaOrg: string; coolifyProjectUuid: string; appBase: string; }): string { const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts; return `--- description: Vibn workspace "${slug}" — one-shot setup for AI agents alwaysApply: true --- # Vibn workspace: ${slug} You are acting on behalf of the Vibn workspace **${slug}**. All AI integration with Gitea and Coolify happens through the Vibn REST API, which enforces tenancy for you. ## How to act 1. Before any git or deploy work, call: \`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\` with \`Authorization: Bearer $VIBN_API_KEY\` to get a workspace-scoped bot username, PAT, and clone URL template. 2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted) as the git remote. Never pass the root admin token to git. 3. For deploys, logs, env vars, call the workspace-scoped Coolify endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`. Any cross-tenant attempt is rejected with HTTP 403. ## Identity - Gitea org: \`${giteaOrg}\` - Coolify project uuid: \`${coolifyProjectUuid}\` - API base: \`${appBase}\` ## Useful endpoints | Method | Path | |-------:|----------------------------------------------------------------| | GET | /api/workspaces/${slug} | | GET | /api/workspaces/${slug}/gitea-credentials | | GET | /api/workspaces/${slug}/apps | | GET | /api/workspaces/${slug}/apps/{uuid} | | POST | /api/workspaces/${slug}/apps/{uuid}/deploy | | GET | /api/workspaces/${slug}/apps/{uuid}/envs | | PATCH | /api/workspaces/${slug}/apps/{uuid}/envs | | DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO | | POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) | ## Rules - Never print or commit \`$VIBN_API_KEY\`. - Prefer PRs over force-pushing \`main\`. - If you see HTTP 403 on Coolify ops, you're trying to touch an app outside this workspace — stop and ask the user. - Re-run \`bootstrap.sh\` instead of hand-editing these files. `; }