diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 8112c2ac..f6688136 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -63,38 +63,59 @@ function buildSystemPrompt(projects: any[], workspace: string): string { You are talking to the owner of the "${workspace}" workspace. ## How Vibn is structured -- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects. -- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project so all its apps + databases + services are grouped together. A project has both: +- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects. You can ONLY see and touch resources in this workspace. +- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project, so all its apps + databases + services are grouped together. A project has two facets that are part of ONE thing — never describe them as separate: - Planning side: name, vision/objectives, requirements (from \`projects_get\`) - - Live side: deployed apps + services (in \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`) - These are facets of ONE thing — never describe them as separate. + - Live side: deployed apps + services (from \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`) -When the user asks about a project, call \`projects_get\` first — it returns both the planning details and the linked deployments. Use \`apps_list { projectId }\` to drill into running services for a specific project, or \`apps_list\` (no args) to see everything in the workspace. +## How to answer questions +- "What is project X?" → \`projects_get { id }\`. The result includes both planning details and the linked deployments. +- "What's running / what has a domain?" → \`apps_list\` (no args) for everything in the workspace, or \`apps_list { projectId }\` for one project. +- "Show me logs / containers / env" → resolve the app uuid first via \`apps_list\`, then call \`apps_logs\` / \`apps_containers_list\` / \`apps_envs_list\`. +- "Find an open source X" → \`github_search\` (always include \`license:mit\` unless the user says otherwise), then \`github_file\` to read READMEs / docker-compose.yml / design system entry points before recommending. +- "What's our docs say about Y?" → \`http_fetch\` against the relevant URL. -When deploying with \`apps_create\`, ALWAYS pass \`projectId\` so the new app/service lands inside the right project's isolated Coolify namespace. If the user hasn't specified a project, ask them which one. +## How to deploy + +**Third-party app (Twenty CRM, n8n, Ghost, Supabase, Pocketbase, etc.)** +1. \`apps_templates_search { query }\` — find the official one-click template. +2. \`apps_create { projectId, name, template, domain }\` — deploy from template into the right project's Coolify namespace. +3. Watch \`apps_get { uuid }\` for status; surface the live URL once \`fqdn\` is set. + +**Custom Docker image** +1. \`apps_create { projectId, name, dockerImage, domain, envsJson }\`. +2. \`apps_deploy { uuid }\` if it doesn't auto-deploy. + +**Database** +1. \`databases_create { projectId, name, type }\` (type: postgres, mysql, redis, mongodb, mariadb, dragonfly, clickhouse, keydb). +2. \`databases_get { uuid }\` returns the internal connection URL — inject it into the app via \`apps_envs_set\`. + +**Domain** +1. \`domains_search { query }\` to check availability + price. +2. \`domains_register { domain }\` to buy it (uses workspace billing). +3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically. + +## 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. +- "tenant" / "does not belong to" errors → the uuid you passed isn't in this workspace. Re-list with \`apps_list\` to grab a valid one. +- Compose stack acting weird → \`apps_repair { uuid }\` to re-apply post-deploy fixes (Traefik labels, port forwarding). +- Need to nuke and re-deploy → \`apps_delete { uuid, confirm }\` (confirm must equal the app's exact name; fetch via \`apps_get\` first), then re-create. + +## Hard rules +- ALWAYS pass \`projectId\` to \`apps_create\` and \`databases_create\`. If the user didn't say which project, ask once, then proceed. +- ALWAYS call \`apps_templates_search\` BEFORE \`apps_create\` when the user names a known third-party app — don't hand-roll a Docker image when a maintained template exists. +- Destructive ops (\`*_delete\`, \`*_volumes_wipe\`) require \`confirm\` equal to the resource's exact name. Always fetch the name first with a \`*_get\` call. +- Long-running ops (deploys, DNS provisioning, db provisioning) take 1–5 min. Tell the user up front so they don't think you're stuck. +- Be concise and action-oriented. If the user says "deploy X", do it — don't write a tutorial. +- After every tool call, summarize the result in 1–2 sentences. Don't dump raw JSON unless asked. +- Format app names, URLs, env keys, UUIDs, and file paths in backticks. +- If a tool errors and you don't understand why, say so honestly and suggest the next diagnostic call. ## Current workspace projects ${projectsText} -## Your capabilities -You have full access to the Vibn platform. You can: -- **Apps**: list, create, update, delete, deploy, get logs, exec commands, manage domains, manage env vars, manage volumes, repair broken deploys -- **Databases**: provision Postgres/MySQL/Redis/MongoDB and 5 other flavors, get connection URLs -- **Auth providers**: deploy Pocketbase, Authentik, Keycloak, Logto, SuperTokens, and others -- **Domains**: search availability, register, attach to apps with full DNS wiring -- **Storage**: provision GCS buckets, inject S3-compatible credentials into apps -- **Templates**: search and deploy from 320+ one-click app templates (n8n, Ghost, Supabase, etc.) -- **GitHub**: search open-source repos, read any file from a public repo -- **Web**: fetch any public URL or documentation page - -## Key rules -- Call \`apps_list\` (not \`projects_list\`) when the user asks what is running or what has a domain. -- Always call \`apps_templates_search\` before \`apps_create\` for any popular third-party app. -- Be concise and action-oriented — if the user wants to deploy something, do it, don't just describe how. -- Confirm app name and domain before deploying unless the user has been explicit. -- After tool calls, summarize in plain language what happened. -- Format code, URLs, and technical values in backticks. -- Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`; +Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`; } export async function POST(request: Request) { diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 78f53199..995d4e71 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -44,7 +44,9 @@ import { import { listContainersForApp } from '@/lib/coolify-containers'; import { deployApplication, - getApplicationInProject, + getApplicationInWorkspace, + getDatabaseInWorkspace, + getServiceInWorkspace, listApplicationDeployments, listApplicationEnvs, listApplicationsInProject, @@ -68,18 +70,16 @@ import { setApplicationDomains, listDatabasesInProject, createDatabase, - getDatabaseInProject, updateDatabase, deleteDatabase, listServicesInProject, createService, - getServiceInProject, deleteService, listServiceTemplates, searchServiceTemplates, type CoolifyDatabaseType, } from '@/lib/coolify'; -import { query } from '@/lib/db-postgres'; +import { query, queryOne } from '@/lib/db-postgres'; import { getRepo } from '@/lib/gitea'; import { giteaHttpsUrl, @@ -540,8 +540,8 @@ async function toolAppsList(principal: Principal, params: Record = }); const serviceList = (Array.isArray(allServicesRes) ? allServicesRes : []) - .filter((s) => envToProject.has(Number(s.environment_id))) - .map((s) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! })); + .filter((s: any) => envToProject.has(Number(s.environment_id))) + .map((s: any) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! })); return NextResponse.json({ result: [ @@ -576,17 +576,19 @@ async function toolAppsList(principal: Principal, params: Record = async function toolAppsGet(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } - const app = await getApplicationInProject(appUuid, projectUuid); + const app = await getApplicationInWorkspace(appUuid, ownedUuids); return NextResponse.json({ result: app }); } async function toolAppsDeploy(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); @@ -594,7 +596,7 @@ async function toolAppsDeploy(principal: Principal, params: Record) // Try Application deploy first; fall back to Service start try { - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const { deployment_uuid } = await deployApplication(appUuid); return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid, resourceType: 'application' } }); } catch (appErr: unknown) { @@ -620,11 +622,12 @@ async function toolAppsDeploy(principal: Principal, params: Record) async function toolAppsDeployments(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const deployments = await listApplicationDeployments(appUuid); return NextResponse.json({ result: deployments }); } @@ -642,11 +645,12 @@ async function toolAppsDeployments(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const linesRaw = Number(params.lines ?? 200); const lines = Number.isFinite(linesRaw) ? linesRaw : 200; @@ -675,6 +679,7 @@ async function toolAppsLogs(principal: Principal, params: Record) { async function toolAppsExec(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); if (!isCoolifySshConfigured()) { return NextResponse.json( @@ -694,7 +699,7 @@ async function toolAppsExec(principal: Principal, params: Record) { { status: 400 }, ); } - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const service = typeof params.service === 'string' && params.service.trim() ? params.service.trim() @@ -739,12 +744,13 @@ async function toolAppsExec(principal: Principal, params: Record) { async function toolAppsVolumesList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 }); } const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const res = await runOnCoolifyHost( `docker volume ls --filter name=${sq(appUuid)} --format '{{.Name}}' | xargs -r -I{} sh -c 'echo "{}|$(docker volume inspect {} --format "{{.UsageData.Size}}" 2>/dev/null || echo -1)"'`, @@ -781,6 +787,7 @@ async function toolAppsVolumesList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 }); } @@ -803,7 +810,7 @@ async function toolAppsVolumesWipe(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const envs = await listApplicationEnvs(appUuid); return NextResponse.json({ result: envs }); } @@ -851,6 +859,7 @@ async function toolAppsEnvsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; const value = typeof params.value === 'string' ? params.value : ''; @@ -860,7 +869,7 @@ async function toolAppsEnvsUpsert(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; if (!appUuid || !key) { @@ -893,7 +903,7 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record) // path when Coolify's start API returns "queued" but no containers // materialise. // -// Tenant safety: the uuid is resolved via getApplicationInProject / -// getServiceInProject, so a workspace can't drive containers it +// Tenant safety: the uuid is resolved via getApplicationInWorkspace / +// getServiceInWorkspace, so a workspace can't drive containers it // doesn't own. /** Resolve a uuid to either an Application or a compose Service in the @@ -1258,8 +1268,9 @@ async function resolveAppOrService( ): Promise<{ uuid: string; kind: ResourceKind } | NextResponse> { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); try { - await getApplicationInProject(uuid, projectUuid); + await getApplicationInWorkspace(uuid, ownedUuids); return { uuid, kind: 'application' }; } catch (e) { if (!(e instanceof Error && /404|not found/i.test(e.message))) { @@ -1269,7 +1280,7 @@ async function resolveAppOrService( } } try { - await getServiceInProject(uuid, projectUuid); + await getServiceInWorkspace(uuid, ownedUuids); return { uuid, kind: 'service' }; } catch (e) { if (e instanceof TenantError) { @@ -1674,10 +1685,11 @@ async function applyEnvsAndDeploy( async function toolAppsUpdate(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const allowed = new Set([ 'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes', @@ -1757,6 +1769,7 @@ async function toolAppsUpdate(principal: Principal, params: Record) async function toolAppsRewireGit(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); @@ -1769,7 +1782,7 @@ async function toolAppsRewireGit(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - const app = await getApplicationInProject(appUuid, projectUuid); + const app = await getApplicationInWorkspace(appUuid, ownedUuids); const confirm = String(params.confirm ?? ''); if (confirm !== app.name) { return NextResponse.json( @@ -1839,9 +1853,10 @@ async function toolAppsDelete(principal: Principal, params: Record) async function toolAppsDomainsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - const app = await getApplicationInProject(appUuid, projectUuid); + const app = await getApplicationInWorkspace(appUuid, ownedUuids); const raw = (app.domains ?? app.fqdn ?? '') as string; const list = raw .split(/[,\s]+/) @@ -1855,12 +1870,13 @@ async function toolAppsDomainsSet(principal: Principal, params: Record ({ @@ -1927,6 +1944,7 @@ async function toolDatabasesCreate(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - const db = await getDatabaseInProject(uuid, projectUuid); + const db = await getDatabaseInWorkspace(uuid, ownedUuids); return NextResponse.json({ result: { uuid: db.uuid, @@ -1986,9 +2005,10 @@ async function toolDatabasesGet(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - await getDatabaseInProject(uuid, projectUuid); + await getDatabaseInWorkspace(uuid, ownedUuids); const allowed = new Set(['name', 'description', 'is_public', 'public_port', 'image', 'limits_memory', 'limits_cpus']); const patch: Record = {}; for (const [k, v] of Object.entries(params)) { @@ -2004,9 +2024,10 @@ async function toolDatabasesUpdate(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - const db = await getDatabaseInProject(uuid, projectUuid); + const db = await getDatabaseInWorkspace(uuid, ownedUuids); const confirm = String(params.confirm ?? ''); if (confirm !== db.name) { return NextResponse.json( @@ -2044,6 +2065,7 @@ const AUTH_PROVIDERS_MCP: Record = { async function toolAuthList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const all = await listServicesInProject(projectUuid); const slugs = new Set(Object.values(AUTH_PROVIDERS_MCP)); return NextResponse.json({ @@ -2065,6 +2087,7 @@ async function toolAuthCreate(principal: Principal, params: Record) const ws = principal.workspace; const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const key = String(params.provider ?? '').toLowerCase().trim(); const coolifyType = AUTH_PROVIDERS_MCP[key]; if (!coolifyType) { @@ -2087,7 +2110,7 @@ async function toolAuthCreate(principal: Principal, params: Record) destinationUuid: ws.coolify_destination_uuid ?? undefined, instantDeploy: params.instantDeploy !== false, }); - const svc = await getServiceInProject(uuid, projectUuid); + const svc = await getServiceInWorkspace(uuid, ownedUuids); return NextResponse.json({ result: { uuid: svc.uuid, name: svc.name, provider: key, status: svc.status ?? null }, }); @@ -2096,9 +2119,10 @@ async function toolAuthCreate(principal: Principal, params: Record) async function toolAuthDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - const svc = await getServiceInProject(uuid, projectUuid); + const svc = await getServiceInWorkspace(uuid, ownedUuids); const confirm = String(params.confirm ?? ''); if (confirm !== svc.name) { return NextResponse.json( @@ -2393,9 +2417,10 @@ async function toolStorageProvision(principal: Principal) { async function toolStorageInjectEnv(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); - await getApplicationInProject(appUuid, projectUuid); + await getApplicationInWorkspace(appUuid, ownedUuids); const prefix = String(params.prefix ?? 'STORAGE_'); if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) { diff --git a/lib/coolify.ts b/lib/coolify.ts index 32f150d8..ef33c5af 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -1152,6 +1152,82 @@ export async function getServiceInProject( return svc; } +// ────────────────────────────────────────────────── +// Workspace-set-aware variants +// ────────────────────────────────────────────────── +// Each Vibn workspace owns multiple Coolify projects (the legacy +// `vibn-ws-{slug}` plus one per Vibn project: `vibn-{slug}-{project-slug}`). +// These helpers verify a resource belongs to ANY of the workspace's owned +// projects — used by single-resource MCP tools (apps.get/delete/exec/etc.) +// so they keep working after we shifted to per-project Coolify projects. + +async function ensureResourceInWorkspaceProjects( + resource: CoolifyApplication | CoolifyDatabase | CoolifyService, + resourceKind: string, + ownedProjectUuids: Set, +): Promise { + if (ownedProjectUuids.size === 0) { + throw new TenantError(`${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`); + } + const explicit = explicitProjectUuidOf(resource); + if (explicit && ownedProjectUuids.has(explicit)) return; + if (explicit && !ownedProjectUuids.has(explicit)) { + throw new TenantError( + `${resourceKind} ${resource.uuid} does not belong to this workspace`, + ); + } + const envId = envIdOf(resource); + if (envId == null) { + throw new TenantError( + `${resourceKind} ${resource.uuid} has no environment_id; cannot verify workspace membership`, + ); + } + // Build env_id → project_uuid map from all owned projects (parallel fetch). + const projects = await Promise.allSettled( + Array.from(ownedProjectUuids).map((uuid) => getProject(uuid)), + ); + const allowedEnvIds = new Set(); + for (const r of projects) { + if (r.status === 'fulfilled') { + for (const env of r.value.environments ?? []) { + if (typeof env.id === 'number') allowedEnvIds.add(env.id); + } + } + } + if (!allowedEnvIds.has(envId)) { + throw new TenantError( + `${resourceKind} ${resource.uuid} does not belong to this workspace`, + ); + } +} + +export async function getApplicationInWorkspace( + appUuid: string, + ownedProjectUuids: Set, +): Promise { + const app = await getApplication(appUuid); + await ensureResourceInWorkspaceProjects(app, 'Application', ownedProjectUuids); + return app; +} + +export async function getDatabaseInWorkspace( + dbUuid: string, + ownedProjectUuids: Set, +): Promise { + const db = await getDatabase(dbUuid); + await ensureResourceInWorkspaceProjects(db, 'Database', ownedProjectUuids); + return db; +} + +export async function getServiceInWorkspace( + serviceUuid: string, + ownedProjectUuids: Set, +): Promise { + const svc = await getService(serviceUuid); + await ensureResourceInWorkspaceProjects(svc, 'Service', ownedProjectUuids); + return svc; +} + /** * Response shape of GET /projects/{uuid}/{envName}. * Coolify splits databases by flavor across sibling arrays. diff --git a/scripts/smoke-chat-tools.ts b/scripts/smoke-chat-tools.ts new file mode 100644 index 00000000..a0971dc0 --- /dev/null +++ b/scripts/smoke-chat-tools.ts @@ -0,0 +1,96 @@ +/** + * Smoke test: validate VIBN_TOOL_DEFINITIONS against the live Gemini API. + * + * Sends the full tool list with a trivial prompt and checks whether Gemini + * accepts the schemas. Catches schema validation errors (ARRAY without items, + * free OBJECT params, etc.) before we deploy. + * + * Usage: GOOGLE_API_KEY=... npx tsx scripts/smoke-chat-tools.ts + * (also picks up GOOGLE_API_KEY from .env.local automatically) + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { VIBN_TOOL_DEFINITIONS } from '../lib/ai/vibn-tools'; + +// Load .env.local manually to avoid needing dotenv as a dep +try { + const envPath = join(process.cwd(), '.env.local'); + const envText = readFileSync(envPath, 'utf-8'); + for (const line of envText.split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim(); + } +} catch {} + +const API_KEY = process.env.GOOGLE_API_KEY; +const MODEL = process.env.VIBN_CHAT_MODEL || 'gemini-3.1-pro-preview'; + +if (!API_KEY) { + console.error('Missing GOOGLE_API_KEY'); + process.exit(1); +} + +async function validateAll() { + console.log(`Validating ${VIBN_TOOL_DEFINITIONS.length} tool definitions against ${MODEL}...\n`); + + const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`; + const body = { + contents: [{ role: 'user', parts: [{ text: 'Show me what is running in my workspace.' }] }], + tools: [{ functionDeclarations: VIBN_TOOL_DEFINITIONS }], + generationConfig: { temperature: 0.0, maxOutputTokens: 200 }, + }; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + + if (!res.ok) { + console.error(`\n❌ FAIL: HTTP ${res.status}`); + console.error(JSON.stringify(data, null, 2)); + + // Try to identify which tools are bad by sending them one at a time + console.log('\n🔍 Bisecting to find broken tools...\n'); + const bad: { name: string; error: string }[] = []; + for (const tool of VIBN_TOOL_DEFINITIONS) { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + tools: [{ functionDeclarations: [tool] }], + generationConfig: { temperature: 0.0, maxOutputTokens: 10 }, + }), + }); + if (!r.ok) { + const err = await r.json(); + const msg = err?.error?.message || JSON.stringify(err).slice(0, 200); + bad.push({ name: tool.name, error: msg }); + console.log(` ❌ ${tool.name}: ${msg.slice(0, 150)}`); + } else { + console.log(` ✓ ${tool.name}`); + } + } + console.log(`\n${bad.length} broken tool(s) out of ${VIBN_TOOL_DEFINITIONS.length}`); + process.exit(1); + } + + console.log('✅ All tool definitions accepted by Gemini.'); + + const calls = data?.candidates?.[0]?.content?.parts + ?.filter((p: any) => p.functionCall) + .map((p: any) => `${p.functionCall.name}(${Object.keys(p.functionCall.args || {}).join(',')})`); + if (calls?.length) { + console.log(`\nGemini chose to call: ${calls.join(', ')}`); + } else { + console.log('\n(No tool calls produced — model responded with text)'); + } +} + +validateAll().catch((e) => { + console.error('Test crashed:', e); + process.exit(1); +});