Files
vibn-frontend/vibn-frontend/app/api/mcp/route.ts

7441 lines
242 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Vibn MCP HTTP bridge.
*
* Authenticates via a workspace-scoped `vibn_sk_...` token (session
* cookies also work for browser debugging). Every tool call is
* executed inside the bound workspace's tenant boundary — Coolify
* requests verify the app's project uuid, and git credentials are
* pinned to the workspace's Gitea org/bot.
*
* Exposed tools are a stable subset of the Vibn REST API so agents
* have one well-typed entry point regardless of deployment host.
*
* Protocol notes:
* - This is a thin, JSON-over-HTTP MCP shim. The `mcp.json` in a
* user's Cursor config points at this URL and stores the bearer
* token. We keep the shape compatible with MCP clients that
* speak `{ action, params }` calls.
*/
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { BigQuery } from "@google-cloud/bigquery";
import { toolBrowserConsole, toolBrowserNavigate } from "./browser";
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
import {
getWorkspaceBotCredentials,
ensureWorkspaceProvisioned,
} from "@/lib/workspaces";
import {
ensureProjectCoolifyProject,
getProjectCoolifyUuid,
getOwnedCoolifyProjectUuids,
getProjectResourceUuids,
linkResourceToProject,
unlinkResource,
} from "@/lib/projects";
import {
ensureWorkspaceGcsProvisioned,
getWorkspaceGcsState,
getWorkspaceGcsHmacCredentials,
} from "@/lib/workspace-gcs";
import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage";
import { coolifyFetch } from "@/lib/coolify";
import { getApplicationRuntimeLogs } from "@/lib/coolify-logs";
import { callVibnChat } from "@/lib/ai/vibn-chat-model";
import { execInCoolifyApp } from "@/lib/coolify-exec";
import { isCoolifySshConfigured, runOnCoolifyHost } from "@/lib/coolify-ssh";
import {
ensureDevContainer,
execInDevContainer,
getDevContainerStatus,
suspendDevContainer,
startDevServer,
stopDevServer,
listDevServers,
tailDevServerLog,
probeDevServerReadiness,
autosaveWorkspace,
PortBusyError,
PortOutOfRangeError,
PREVIEW_BASE_PORT,
PREVIEW_PORT_COUNT,
} from "@/lib/dev-container";
import { isPathBDisabled } from "@/lib/feature-flags";
import {
ensureSentryProject,
applySentryEnvToCoolifyApp,
listRecentSentryIssues,
getSentryIssueDetail,
resolveSentryIssue,
} from "@/lib/integrations/sentry";
import {
buildDesignKitToolPayload,
parsePersistedDesignKit,
} from "@/lib/design-kits/for-ai";
import { QuotaExceededError } from "@/lib/quotas";
import {
composeUp,
composePs,
applyCoolifyPostDeployFixes,
type CoolifyPostDeployResult,
type ResourceKind,
} from "@/lib/coolify-compose";
import { listContainersForApp } from "@/lib/coolify-containers";
import {
deployApplication,
getApplicationInWorkspace,
getDatabaseInWorkspace,
getServiceInWorkspace,
listApplicationDeployments,
listApplicationEnvs,
listApplicationsInProject,
getProject,
projectUuidOf,
TenantError,
upsertApplicationEnv,
deleteApplicationEnv,
// Phase 4 ── create/update/delete + domains + databases + services
createPublicApp,
createPrivateDeployKeyApp,
createDockerImageApp,
createDockerComposeApp,
startService,
stopService,
getService,
listAllServices,
listServiceEnvs,
upsertServiceEnv,
setServiceDomains,
updateApplication,
deleteApplication,
setApplicationDomains,
listDatabasesInProject,
createDatabase,
updateDatabase,
deleteDatabase,
listServicesInProject,
createService,
deleteService,
listServiceTemplates,
searchServiceTemplates,
type CoolifyDatabaseType,
} from "@/lib/coolify";
import { query, queryOne } from "@/lib/db-postgres";
import {
getRepo,
createRepo,
giteaPushFile,
giteaReadFile,
giteaListContents,
giteaListBranches,
giteaCreateBranch,
giteaListOrgRepos,
giteaDeleteFile,
} from "@/lib/gitea";
import {
giteaHttpsUrl,
isDomainUnderWorkspace,
slugify,
toDomainsString,
workspaceAppFqdn,
} from "@/lib/naming";
const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com";
// ──────────────────────────────────────────────────
// Capability descriptor
// ──────────────────────────────────────────────────
export async function GET() {
return NextResponse.json({
name: "vibn-mcp",
version: "2.7.0",
authentication: {
scheme: "Bearer",
tokenPrefix: "vibn_sk_",
description:
"Workspace-scoped token minted at /settings. Every tool call is " +
"automatically restricted to the workspace the token belongs to.",
},
capabilities: {
tools: {
supported: true,
available: [
"workspace.describe",
"gitea.credentials",
"projects.list",
"projects.get",
"project.recent_errors",
"project.error_detail",
"project.error_resolve",
"apps.list",
"apps.get",
"apps.create",
"apps.update",
"apps.rewire_git",
"apps.delete",
"apps.deploy",
"apps.deployments",
"apps.domains.list",
"apps.domains.set",
"apps.logs",
"apps.exec",
"apps.volumes.list",
"apps.volumes.wipe",
"apps.containers.up",
"apps.containers.ps",
"apps.repair",
"apps.templates.list",
"apps.templates.search",
"apps.envs.list",
"apps.envs.upsert",
"apps.envs.delete",
"workspace.db_query",
"databases.list",
"databases.create",
"databases.get",
"databases.update",
"databases.delete",
"auth.list",
"auth.create",
"auth.delete",
"domains.search",
"domains.list",
"domains.get",
"domains.register",
"domains.attach",
"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",
"devcontainer.ensure",
"devcontainer.status",
"devcontainer.suspend",
"shell.exec",
"fs.read",
"fs.write",
"fs.edit",
"apps.templates.scaffold",
"generate_media",
"fs.list",
"fs.tree",
"fs.delete",
"fs.glob",
"fs.grep",
"dev_server.start",
"dev_server.stop",
"dev_server.list",
"dev_server.logs",
"browser.console",
"browser.navigate",
"ship",
"request_visual_qa",
],
},
},
documentation: "https://vibnai.com/docs/mcp",
});
}
// ──────────────────────────────────────────────────
// Tool dispatcher
// ──────────────────────────────────────────────────
export async function POST(request: Request) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const vibnProjectId = request.headers.get("X-Vibn-Project-Id") || null;
let body: {
action?: string;
tool?: string;
params?: Record<string, unknown>;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Accept either `{ action, params }` or `{ tool, params }` shapes.
const action = (body.tool ?? body.action ?? "") as string;
const params = (body.params ?? {}) as Record<string, any>;
if (vibnProjectId) {
const bannedTools = new Set(["workspace.describe", "projects.list"]);
if (bannedTools.has(action)) {
return NextResponse.json(
{ error: `Tool ${action} is banned in an isolated project context.` },
{ status: 403 },
);
}
// Forcefully override projectId
params.projectId = vibnProjectId;
}
try {
switch (action) {
case "workspace.describe":
return NextResponse.json({ result: describeWorkspace(principal) });
case "gitea.credentials":
return await toolGiteaCredentials(principal);
case "projects.list":
return await toolProjectsList(principal);
case "projects.get":
return await toolProjectsGet(principal, params);
case "email.send":
return await toolEmailSend(principal, params);
case "project.recent_errors":
case "project.recent.errors":
return await toolProjectRecentErrors(principal, params);
case "project.error_detail":
case "project.error.detail":
return await toolProjectErrorDetail(principal, params);
case "project.error_resolve":
case "project.error.resolve":
return await toolProjectErrorResolve(principal, params);
case "apps.list":
return await toolAppsList(principal, params);
case "apps.get":
return await toolAppsGet(principal, params);
case "apps.deploy":
return await toolAppsDeploy(principal, params);
case "apps.deployments":
return await toolAppsDeployments(principal, params);
case "apps.envs.list":
return await toolAppsEnvsList(principal, params);
case "apps.envs.upsert":
return await toolAppsEnvsUpsert(principal, params);
case "apps.envs.delete":
return await toolAppsEnvsDelete(principal, params);
case "apps.create":
return await toolAppsCreate(principal, params);
case "apps.update":
return await toolAppsUpdate(principal, params);
case "apps.rewire_git":
case "apps.rewire.git":
return await toolAppsRewireGit(principal, params);
case "apps.delete":
return await toolAppsDelete(principal, params);
case "apps.domains.list":
return await toolAppsDomainsList(principal, params);
case "apps.domains.set":
return await toolAppsDomainsSet(principal, params);
case "apps.logs":
return await toolAppsLogs(principal, params);
case "apps.exec":
return await toolAppsExec(principal, params);
case "apps.volumes.list":
return await toolAppsVolumesList(principal, params);
case "apps.volumes.wipe":
return await toolAppsVolumesWipe(principal, params);
case "apps.containers.up":
return await toolAppsContainersUp(principal, params);
case "apps.containers.ps":
return await toolAppsContainersPs(principal, params);
case "apps.repair":
return await toolAppsRepair(principal, params);
case "apps.unstick":
return await toolAppsUnstick(principal, params);
case "apps.templates.list":
return await toolAppsTemplatesList(params);
case "apps.templates.search":
return await toolAppsTemplatesSearch(params);
case "workspace.db_query":
return await toolWorkspaceDbQuery(principal, params);
case "databases.list":
return await toolDatabasesList(principal);
case "databases.create":
return await toolDatabasesCreate(principal, params);
case "databases.get":
return await toolDatabasesGet(principal, params);
case "databases.update":
return await toolDatabasesUpdate(principal, params);
case "databases.delete":
return await toolDatabasesDelete(principal, params);
case "databases.logs":
return await toolDatabasesLogs(principal, params);
case "deployments.logs":
return await toolDeploymentsLogs(principal, params);
case "auth.list":
return await toolAuthList(principal);
case "auth.create":
return await toolAuthCreate(principal, params);
case "auth.delete":
return await toolAuthDelete(principal, params);
case "domains.search":
return await toolDomainsSearch(principal, params);
case "domains.list":
return await toolDomainsList(principal);
case "domains.get":
return await toolDomainsGet(principal, params);
case "domains.register":
return await toolDomainsRegister(principal, params);
case "domains.attach":
return await toolDomainsAttach(principal, params);
case "storage.describe":
return await toolStorageDescribe(principal);
case "storage.provision":
return await toolStorageProvision(principal);
case "storage.inject_env":
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);
case "devcontainer.ensure":
return await toolDevContainerEnsure(principal, params);
case "devcontainer.status":
return await toolDevContainerStatus(principal, params);
case "devcontainer.suspend":
return await toolDevContainerSuspend(principal, params);
case "shell.exec":
return await toolShellExec(principal, params);
case "fs.read":
case "fs_read":
return await toolFsRead(principal, params);
case "request_visual_qa":
case "request.visual.qa":
return await toolRequestVisualQA(principal, params);
case "fs.write":
case "fs_write":
return await toolFsWrite(principal, params);
case "fs.edit":
case "fs_edit":
return await toolFsEdit(principal, params);
case "get_design_template":
case "get.design.template":
return await toolGetDesignTemplate(params);
case "apps.templates.scaffold":
return await toolAppsTemplatesScaffold(principal, params);
case "generate_media":
case "generate.media":
return await toolGenerateMedia(principal, params);
case "fs.list":
case "fs_list":
return await toolFsList(principal, params);
case "fs.tree":
case "fs_tree":
return await toolFsTree(principal, params);
case "fs.delete":
case "fs_delete":
return await toolFsDelete(principal, params);
case "fs.glob":
case "fs_glob":
return await toolFsGlob(principal, params);
case "fs.grep":
case "fs_grep":
return await toolFsGrep(principal, params);
// The Gemini tool-name "dev_server_list" maps to dotted action
// "dev.server.list" via executeMcpTool's underscore→dot replace.
// We accept BOTH the original underscore form and the converted
// dotted form so external MCP clients aren't surprised either way.
case "dev_server.start":
case "dev.server.start":
return await toolDevServerStart(principal, params);
case "dev_server.stop":
case "dev.server.stop":
return await toolDevServerStop(principal, params);
case "dev_server.list":
case "dev.server.list":
return await toolDevServerList(principal, params);
case "dev_server.logs":
case "dev.server.logs":
return await toolDevServerLogs(principal, params);
case "browser.console":
case "browser_console":
return await toolBrowserConsole(
String(params.projectId),
String(params.url),
);
case "browser.navigate":
case "browser_navigate":
return await toolBrowserNavigate(
String(params.projectId),
String(params.url),
);
case "ship":
return await toolShip(principal, params);
case "services.list":
return await toolServicesList(principal, params);
case "services.get":
return await toolServicesGet(principal, params);
case "services.start":
return await toolServicesStart(principal, params);
case "services.stop":
return await toolServicesStop(principal, params);
case "services.envs.list":
return await toolServicesEnvsList(principal, params);
case "services.envs.upsert":
return await toolServicesEnvsUpsert(principal, params);
case "plan.get":
return await toolPlanGet(principal, params);
case "plan.vision.set":
return await toolPlanVisionSet(principal, params);
case "plan.idea.add":
return await toolPlanIdeaAdd(principal, params);
case "plan.task.add":
return await toolPlanTaskAdd(principal, params);
case "plan.task.edit":
return await toolPlanTaskEdit(principal, params);
case "plan.task.complete":
return await toolPlanTaskComplete(principal, params);
case "plan.document.update":
return await toolPlanDocumentUpdate(principal, params);
case "tech_stack_analyze":
case "tech.stack.analyze":
return await toolTechStackAnalyze(principal, params);
case "market_competitor_research":
case "market.competitor.research":
return await toolMarketCompetitorResearch(principal, params);
case "market_aggregate_insights":
case "market.aggregate.insights":
return await toolMarketAggregateInsights(principal, params);
case "market_seo_analyze":
case "market.seo.analyze":
return await toolMarketSeoAnalyze(principal, params);
case "market_categories_suggest":
case "market.categories.suggest":
return await toolMarketCategoriesSuggest(principal, params);
case "market_research_run":
case "market.research.run":
return await toolMarketResearchRun(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
{ status: 404 },
);
}
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
console.error("[mcp] tool failed", action, err);
return NextResponse.json(
{
error: "Tool execution failed",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
}
// ──────────────────────────────────────────────────
// Tool implementations
// ──────────────────────────────────────────────────
type Principal = Extract<
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
{ source: "session" | "api_key" }
>;
function describeWorkspace(principal: Principal) {
const w = principal.workspace;
return {
slug: w.slug,
name: w.name,
coolifyProjectUuid: w.coolify_project_uuid,
giteaOrg: w.gitea_org,
giteaBotUsername: w.gitea_bot_username,
provisionStatus: w.provision_status,
provisionError: w.provision_error,
principal: {
source: principal.source,
apiKeyId: principal.apiKeyId ?? null,
},
};
}
async function toolGiteaCredentials(principal: Principal) {
let ws = principal.workspace;
if (!ws.gitea_bot_token_encrypted || !ws.gitea_org) {
ws = await ensureWorkspaceProvisioned(ws);
}
const creds = getWorkspaceBotCredentials(ws);
if (!creds) {
return NextResponse.json(
{
error: "Workspace has no Gitea bot yet",
provisionStatus: ws.provision_status,
},
{ status: 503 },
);
}
const apiBase = GITEA_API_URL.replace(/\/$/, "");
const host = new URL(apiBase).host;
return NextResponse.json({
result: {
org: creds.org,
username: creds.username,
token: creds.token,
apiBase,
host,
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
},
});
}
async function toolProjectsList(principal: Principal) {
const rows = await query<{
id: string;
data: any;
created_at: Date;
updated_at: Date;
}>(
`SELECT id, data, created_at, updated_at
FROM fs_projects
WHERE vibn_workspace_id = $1
OR workspace = $2
ORDER BY created_at DESC`,
[principal.workspace.id, principal.workspace.slug],
);
return NextResponse.json({
result: rows.map((r) => ({
id: r.id,
name: r.data?.name ?? null,
repo: r.data?.repoName ?? null,
giteaRepo: r.data?.giteaRepo ?? null,
coolifyAppUuid: r.data?.coolifyAppUuid ?? null,
createdAt: r.created_at,
updatedAt: r.updated_at,
})),
});
}
async function toolProjectsGet(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? params.id ?? "").trim();
if (!projectId) {
return NextResponse.json(
{ error: 'Param "projectId" is required' },
{ status: 400 },
);
}
const rows = await query<{
id: string;
data: any;
created_at: Date;
updated_at: Date;
}>(
`SELECT id, data, created_at, updated_at
FROM fs_projects
WHERE id = $1
AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug],
);
if (rows.length === 0) {
return NextResponse.json(
{ error: "Project not found in this workspace" },
{ status: 404 },
);
}
const r = rows[0];
const d = r.data || {};
const projectName = d.productName || d.name || d.title || "Untitled";
// Auto-enrich: if no Coolify link is stored yet, scan apps + services in
// the workspace and surface any whose name fuzzy-matches the project. Lets
// the AI tell the user "this is probably your deployment" even when the
// backend never wrote the link.
let possibleDeployments: Array<{
uuid: string;
name: string;
status: string;
fqdn: string | null;
resourceType: "application" | "service";
}> = [];
const linkedUuid = d.coolifyAppUuid || d.coolifyServiceUuid || null;
const projectUuid = principal.workspace.coolify_project_uuid;
// Authoritative source: explicit project↔resource links from fs_project_resources.
// The fuzzy-match below is only a fallback for legacy projects that haven't
// been backfilled yet.
const explicitLinks = await getProjectResourceUuids(r.id);
if (projectUuid) {
try {
const [appsRes, servicesRes, projectRes] = await Promise.allSettled([
listApplicationsInProject(projectUuid),
listAllServices(),
getProject(projectUuid),
]);
const envIds = new Set<number>(
projectRes.status === "fulfilled"
? (projectRes.value.environments ?? []).map((e) => e.id)
: [],
);
const apps = appsRes.status === "fulfilled" ? appsRes.value : [];
const services = (
servicesRes.status === "fulfilled" && Array.isArray(servicesRes.value)
? (servicesRes.value as Array<Record<string, unknown>>)
: []
).filter((s) => envIds.has(Number(s.environment_id)));
// Build searchable tokens from the project name (lowercased words, length >= 3)
const tokens = projectName
.toLowerCase()
.replace(/[^a-z0-9 ]/g, " ")
.split(/\s+/)
.filter((t: string) => t.length >= 3);
const matches = (name: string) => {
const n = name.toLowerCase();
return tokens.some((t: string) => n.includes(t));
};
const hasExplicit = explicitLinks.size > 0;
for (const a of apps) {
const isLinked = explicitLinks.has(a.uuid) || a.uuid === linkedUuid;
// If we have explicit links, ONLY include linked resources. Otherwise fall back to fuzzy match.
if (isLinked || (!hasExplicit && matches(a.name))) {
possibleDeployments.push({
uuid: a.uuid,
name: a.name,
status: a.status,
fqdn: a.fqdn ?? null,
resourceType: "application",
});
}
}
for (const s of services) {
const name = String(s.name ?? "");
const uuid = String(s.uuid);
const isLinked = explicitLinks.has(uuid) || uuid === linkedUuid;
if (isLinked || (!hasExplicit && matches(name))) {
const subApps =
(s.applications as Array<Record<string, unknown>>) || [];
const publicApp = subApps.find((a) => a.fqdn);
possibleDeployments.push({
uuid,
name,
status: String(s.status ?? "unknown"),
fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null,
resourceType: "service",
});
}
}
} catch {
// Best-effort enrichment — never let it block the project read
}
}
const designKitPersisted = parsePersistedDesignKit(d.designKit);
const designKitForCodegen = buildDesignKitToolPayload({
designKit: d.designKit,
});
return NextResponse.json({
result: {
id: r.id,
name: projectName,
status: d.status || "defining",
vision: d.productVision || d.vision || null,
domain: d.domain || d.customDomain || null,
coolifyAppUuid: linkedUuid,
coolifyDomain: d.coolifyDomain || null,
repositoryUrl: d.repositoryUrl || null,
possibleDeployments,
// Persisted Design-tab kit (normalized). Null if never saved.
designKit: designKitPersisted,
// Resolved ramps + radii + apply hint for wiring globals.css / Tailwind / etc.
designKitForCodegen,
// Sentry-as-product: surface the project's Sentry slug + DSN
// so the AI can wire withSentryConfig({ org, project: <slug> })
// when scaffolding apps. DSN is also injected as a Coolify env
// var by apps.create automatically — see SENTRY_AS_PRODUCT.md.
sentry: d.sentry
? {
slug: d.sentry.slug,
dsn: d.sentry.dsn,
provisionedAt: d.sentry.provisionedAt,
}
: null,
createdAt: r.created_at,
updatedAt: r.updated_at,
},
});
}
async function toolEmailSend(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? params.id ?? "").trim();
if (!projectId) {
return NextResponse.json(
{ error: 'Param "projectId" is required' },
{ status: 400 },
);
}
const { to, subject, text, mjml } = params;
if (!to || !subject || !text) {
return NextResponse.json(
{ error: "Missing required email parameters (to, subject, text)." },
{ status: 400 },
);
}
// Future feature: Look up project.email_config in Postgres for custom domains/API keys.
// For now, it uses the global ENV variable.
try {
const { sendEmail } = await import("@/lib/email/mailgun");
const response = await sendEmail({
to,
subject,
text,
mjml,
});
if (!response.success) {
return NextResponse.json({ error: response.error }, { status: 500 });
}
return NextResponse.json({ result: response });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* Tenant-safe lookup: confirms the project belongs to the caller's
* workspace before exposing Sentry data. Returns null + error
* response if not, the project id (string) if yes. Used by all
* three Sentry tools below.
*/
async function projectInWorkspace(
principal: Principal,
projectId: string,
): Promise<string | NextResponse> {
if (!projectId) {
return NextResponse.json(
{ error: 'Param "projectId" is required' },
{ status: 400 },
);
}
const rows = await query<{ id: string }>(
`SELECT id FROM fs_projects
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug],
);
if (rows.length === 0) {
return NextResponse.json(
{ error: "Project not found in this workspace" },
{ status: 404 },
);
}
return projectId;
}
async function toolProjectRecentErrors(
principal: Principal,
params: Record<string, any>,
) {
const projectId = await projectInWorkspace(
principal,
String(params.projectId ?? "").trim(),
);
if (projectId instanceof NextResponse) return projectId;
const sinceHours = clampNumber(params.sinceHours, {
min: 1,
max: 168,
fallback: 24,
});
const limit = clampNumber(params.limit, { min: 1, max: 50, fallback: 10 });
const issues = await listRecentSentryIssues(projectId, { sinceHours, limit });
return NextResponse.json({
result: { issues, count: issues.length, sinceHours },
});
}
async function toolProjectErrorDetail(
principal: Principal,
params: Record<string, any>,
) {
const projectId = await projectInWorkspace(
principal,
String(params.projectId ?? "").trim(),
);
if (projectId instanceof NextResponse) return projectId;
const issueId = String(params.issueId ?? "").trim();
if (!issueId) {
return NextResponse.json(
{ error: 'Param "issueId" is required' },
{ status: 400 },
);
}
const detail = await getSentryIssueDetail(projectId, issueId);
if (!detail) {
return NextResponse.json(
{ error: "Issue not found or Sentry not provisioned for this project" },
{ status: 404 },
);
}
return NextResponse.json({ result: detail });
}
async function toolProjectErrorResolve(
principal: Principal,
params: Record<string, any>,
) {
const projectId = await projectInWorkspace(
principal,
String(params.projectId ?? "").trim(),
);
if (projectId instanceof NextResponse) return projectId;
const issueId = String(params.issueId ?? "").trim();
if (!issueId) {
return NextResponse.json(
{ error: 'Param "issueId" is required' },
{ status: 400 },
);
}
const ok = await resolveSentryIssue(projectId, issueId);
if (!ok) {
return NextResponse.json(
{
error:
"Resolve failed (Sentry not provisioned, issue not found, or token rejected)",
},
{ status: 502 },
);
}
return NextResponse.json({ result: { resolved: true, issueId } });
}
function clampNumber(
raw: unknown,
opts: { min: number; max: number; fallback: number },
): number {
const n = Number(raw);
if (!Number.isFinite(n)) return opts.fallback;
return Math.max(opts.min, Math.min(opts.max, Math.floor(n)));
}
function requireCoolifyProject(principal: Principal): string | NextResponse {
const projectUuid = principal.workspace.coolify_project_uuid;
if (!projectUuid) {
return NextResponse.json(
{ error: "Workspace has no Coolify project yet" },
{ status: 503 },
);
}
return projectUuid;
}
async function toolAppsList(
principal: Principal,
params: Record<string, any> = {},
) {
// Resolve which Coolify projects to scan AND which resource UUIDs (if any)
// are explicitly linked to a single Vibn project via fs_project_resources.
//
// Semantics:
// - No `projectId` → return everything in every Coolify project owned by the workspace.
// - `projectId` + dedicated → return everything in that Vibn project's dedicated Coolify project,
// plus any extra resources explicitly linked.
// - `projectId` + legacy → ONLY explicitly-linked resources (the dedicated project equals
// shared the legacy workspace project, so a raw scan would leak unrelated
// services like an unrelated n8n deployment).
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
let targetUuids: string[];
let explicitLinked: Map<string, string> = new Map(); // resource_uuid → resource_type
let restrictToExplicit = false;
if (params.projectId) {
const projectCoolify = await getProjectCoolifyUuid(
String(params.projectId),
principal.workspace,
);
if (!projectCoolify) {
return NextResponse.json(
{ error: `Project ${params.projectId} not found in this workspace` },
{ status: 404 },
);
}
explicitLinked = await getProjectResourceUuids(String(params.projectId));
const legacyUuid = principal.workspace.coolify_project_uuid;
if (legacyUuid && projectCoolify === legacyUuid) {
restrictToExplicit = true;
targetUuids = [projectCoolify];
} else {
targetUuids = [projectCoolify];
}
} else {
targetUuids = Array.from(ownedUuids);
if (targetUuids.length === 0 && principal.workspace.coolify_project_uuid) {
targetUuids = [principal.workspace.coolify_project_uuid];
}
}
if (targetUuids.length === 0) {
return NextResponse.json({ result: [] });
}
// Fetch apps + services in parallel; services need env_id → project_uuid resolution.
const [appsResults, allServicesRes, projectsResults] = await Promise.all([
Promise.allSettled(
targetUuids.map((uuid) => listApplicationsInProject(uuid)),
),
listAllServices().catch(() => [] as Array<Record<string, unknown>>),
Promise.allSettled(targetUuids.map((uuid) => getProject(uuid))),
]);
const appList = appsResults.flatMap((r, i) =>
r.status === "fulfilled"
? r.value.map((a) => ({ ...a, _coolifyProjectUuid: targetUuids[i] }))
: [],
);
// Build env_id → coolify_project_uuid map for service filtering
const envToProject = new Map<number, string>();
projectsResults.forEach((r, i) => {
if (r.status === "fulfilled") {
for (const env of r.value.environments ?? []) {
envToProject.set(env.id, targetUuids[i]);
}
}
});
const serviceList = (Array.isArray(allServicesRes) ? allServicesRes : [])
.filter((s: any) => envToProject.has(Number(s.environment_id)))
.map((s: any) => ({
...s,
_coolifyProjectUuid: envToProject.get(Number(s.environment_id))!,
}));
const filteredApps = restrictToExplicit
? appList.filter((a) => explicitLinked.has(a.uuid))
: appList;
const filteredServices = restrictToExplicit
? serviceList.filter((s) => explicitLinked.has(String(s.uuid)))
: serviceList;
return NextResponse.json({
result: [
...filteredApps.map((a) => ({
uuid: a.uuid,
name: a.name,
status: a.status,
fqdn: a.fqdn ?? null,
gitRepository: a.git_repository ?? null,
gitBranch: a.git_branch ?? null,
resourceType: "application",
coolifyProjectUuid: (a as any)._coolifyProjectUuid as string,
})),
...filteredServices.map((s) => {
const apps = (s.applications as Array<Record<string, unknown>>) || [];
const publicApp = apps.find((a) => a.fqdn);
return {
uuid: String(s.uuid),
name: String(s.name ?? ""),
status: String(s.status ?? "unknown"),
fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null,
gitRepository: null,
gitBranch: null,
resourceType: "service" as const,
coolifyProjectUuid: (s as any)._coolifyProjectUuid as string,
};
}),
],
});
}
async function toolAppsGet(principal: Principal, params: Record<string, any>) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
return NextResponse.json({ result: app });
}
async function toolAppsDeploy(
principal: Principal,
params: Record<string, any>,
) {
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 },
);
}
// Try Application deploy first; fall back to Service start
try {
await getApplicationInWorkspace(appUuid, ownedUuids);
const { deployment_uuid } = await deployApplication(appUuid);
return NextResponse.json({
result: {
deploymentUuid: deployment_uuid,
appUuid,
resourceType: "application",
},
});
} catch (appErr: unknown) {
// Check if it's a Service (compose stack)
try {
const svc = await getService(appUuid);
// Verify it belongs to this workspace's project
const svcProjectUuid =
svc.project_uuid ??
svc.environment?.project_uuid ??
svc.environment?.project?.uuid;
if (svcProjectUuid !== projectUuid) {
return NextResponse.json(
{ error: "Service not found in this workspace" },
{ status: 404 },
);
}
await startService(appUuid);
return NextResponse.json({
result: {
appUuid,
resourceType: "service",
message: "Service start queued",
},
});
} catch {
// Re-throw original error
throw appErr;
}
}
}
async function toolAppsDeployments(
principal: Principal,
params: Record<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const deployments = await listApplicationDeployments(appUuid);
return NextResponse.json({ result: deployments });
}
/**
* Runtime logs for a Coolify app. Compose-aware:
* - Dockerfile/nixpacks apps → Coolify's `/applications/{uuid}/logs`
* - Compose apps → SSH into Coolify host, `docker logs` per service
*
* Params:
* uuid app uuid (required)
* service compose service filter (optional)
* lines tail lines per container, default 200, max 5000
*/
async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const linesRaw = Number(params.lines ?? 200);
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
const serviceRaw = params.service;
const service =
typeof serviceRaw === "string" && serviceRaw.trim()
? serviceRaw.trim()
: undefined;
const result = await getApplicationRuntimeLogs(appUuid, { lines, service });
return NextResponse.json({ result });
}
/**
* apps.exec — run a one-shot command inside an app container.
*
* Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller
* provides `uuid`, an optional `service` (required for compose apps
* with >1 container), and a `command` string. Output is capped at
* 1MB by default and 10-minute wall-clock timeout.
*
* Note: the command is NOT parsed or validated. It's executed as a
* single shell invocation inside the container via `sh -lc`. This is
* deliberate — this tool is the platform's trust-the-agent escape
* hatch for migrations, CLI invocations, and ad-hoc debugging. It's
* authenticated per-workspace (tenant check above) and rate-limited
* by the SSH session's timeout/byte caps.
*/
async function toolAppsExec(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
"apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.",
},
{ status: 501 },
);
}
const appUuid = String(params.uuid ?? params.appUuid ?? "").trim();
const command = typeof params.command === "string" ? params.command : "";
if (!appUuid || !command) {
return NextResponse.json(
{ error: 'Params "uuid" and "command" are required' },
{ status: 400 },
);
}
await getApplicationInWorkspace(appUuid, ownedUuids);
const service =
typeof params.service === "string" && params.service.trim()
? params.service.trim()
: undefined;
const user =
typeof params.user === "string" && params.user.trim()
? params.user.trim()
: undefined;
const workdir =
typeof params.workdir === "string" && params.workdir.trim()
? params.workdir.trim()
: undefined;
const timeoutMs = Number.isFinite(Number(params.timeout_ms))
? Number(params.timeout_ms)
: undefined;
const maxBytes = Number.isFinite(Number(params.max_bytes))
? Number(params.max_bytes)
: undefined;
try {
const result = await execInCoolifyApp({
appUuid,
service,
command,
user,
workdir,
timeoutMs,
maxBytes,
});
return NextResponse.json({ result });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 400 });
}
}
// ── Volume tools ────────────────────────────────────────────────────────
/**
* apps.volumes.list — list Docker volumes that belong to an app.
* Returns name, size (bytes), and which containers are currently
* using each volume (if any are running).
*/
async function toolAppsVolumesList(
principal: Principal,
params: Record<string, any>,
) {
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 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)"'`,
{ timeoutMs: 12_000 },
);
if (res.code !== 0) {
return NextResponse.json(
{ error: `docker volume ls failed: ${res.stderr.trim()}` },
{ status: 502 },
);
}
const volumes = res.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [name, sizeStr] = l.split("|");
const sizeBytes = parseInt(sizeStr ?? "-1", 10);
return { name, sizeBytes: isNaN(sizeBytes) ? -1 : sizeBytes };
});
return NextResponse.json({ result: { volumes } });
}
/**
* apps.volumes.wipe — destroy a Docker volume for this app.
*
* This is a destructive, irreversible operation. The agent MUST pass
* `confirm: "<volume-name>"` exactly matching the volume name, to
* prevent accidents. All containers using the volume are stopped and
* removed first (Coolify will restart them on the next deploy).
*
* Typical use: wipe a stale Postgres data volume before redeploying
* so the database is initialised fresh.
*/
async function toolAppsVolumesWipe(
principal: Principal,
params: Record<string, any>,
) {
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 },
);
}
const appUuid = String(params.uuid ?? params.appUuid ?? "").trim();
const volumeName = String(params.volume ?? "").trim();
const confirm = String(params.confirm ?? "").trim();
if (!appUuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
if (!volumeName)
return NextResponse.json(
{
error:
'Param "volume" is required (exact volume name from apps.volumes.list)',
},
{ status: 400 },
);
if (confirm !== volumeName) {
return NextResponse.json(
{
error: `Param "confirm" must equal the exact volume name "${volumeName}" to proceed`,
},
{ status: 400 },
);
}
// Security check: volume must belong to this app (name must contain the uuid)
if (!volumeName.includes(appUuid)) {
return NextResponse.json(
{
error: `Volume "${volumeName}" does not appear to belong to app ${appUuid}`,
},
{ status: 403 },
);
}
await getApplicationInWorkspace(appUuid, ownedUuids);
// Stop + remove all containers using this volume, then remove the volume
const cmd = [
// Stop and remove containers for this app (they'll be recreated on next deploy)
`CONTAINERS=$(docker ps -a --filter name=${sq(appUuid)} --format '{{.Names}}')`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker stop -t 10 || true`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker rm -f || true`,
// Remove the volume
`docker volume rm ${sq(volumeName)}`,
`echo "done"`,
].join(" && ");
const res = await runOnCoolifyHost(cmd, { timeoutMs: 30_000 });
if (res.code !== 0 || !res.stdout.includes("done")) {
return NextResponse.json(
{
error: `Volume removal failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.trim()}`,
},
{ status: 502 },
);
}
return NextResponse.json({
result: {
wiped: volumeName,
message:
"Volume removed. Trigger apps.deploy to restart the app with a fresh volume.",
},
});
}
function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
async function toolAppsEnvsList(
principal: Principal,
params: Record<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const envs = await listApplicationEnvs(appUuid);
return NextResponse.json({ result: envs });
}
async function toolAppsEnvsUpsert(
principal: Principal,
params: Record<string, any>,
) {
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 : "";
if (!appUuid || !key) {
return NextResponse.json(
{ error: 'Params "uuid" and "key" are required' },
{ status: 400 },
);
}
await getApplicationInWorkspace(appUuid, ownedUuids);
// Coolify v4 rejects `is_build_time` on POST/PATCH (it's a derived
// read-only flag now). Silently drop it here so agents that still send
// it don't get a surprise 422. See lib/coolify.ts upsertApplicationEnv
// for the hard enforcement at the network boundary.
const result = await upsertApplicationEnv(appUuid, {
key,
value,
is_preview: !!params.is_preview,
is_literal: !!params.is_literal,
is_multiline: !!params.is_multiline,
is_shown_once: !!params.is_shown_once,
});
const body: Record<string, unknown> = { result };
if (params.is_build_time !== undefined) {
body.warnings = [
"is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.",
];
}
return NextResponse.json(body);
}
async function toolAppsEnvsDelete(
principal: Principal,
params: Record<string, any>,
) {
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) {
return NextResponse.json(
{ error: 'Params "uuid" and "key" are required' },
{ status: 400 },
);
}
await getApplicationInWorkspace(appUuid, ownedUuids);
await deleteApplicationEnv(appUuid, key);
return NextResponse.json({ result: { ok: true, key } });
}
// ──────────────────────────────────────────────────
// services.* — Coolify Services (Twenty CRM, n8n, etc.)
//
// Services are upstream Docker images that Coolify pulls and runs as
// docker-compose stacks. Distinct from apps (which build from Git).
// All ops are tenant-scoped via the workspace's owned Coolify projects
// — agents from one workspace cannot read or mutate another's services.
// ──────────────────────────────────────────────────
async function toolServicesList(
principal: Principal,
params: Record<string, any> = {},
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
// Mirror apps.list scoping: optional `projectId` narrows to a single
// Vibn project's Coolify env; otherwise scan everything we own.
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
let target: string[];
if (params.projectId) {
const pUuid = await getProjectCoolifyUuid(
String(params.projectId),
principal.workspace,
);
if (!pUuid)
return NextResponse.json(
{ error: "Project not found in this workspace" },
{ status: 404 },
);
target = [pUuid];
} else {
target = Array.from(ownedUuids);
if (target.length === 0 && principal.workspace.coolify_project_uuid) {
target = [principal.workspace.coolify_project_uuid];
}
}
if (target.length === 0) return NextResponse.json({ result: [] });
const results = await Promise.allSettled(
target.map((uuid) => listServicesInProject(uuid)),
);
const services = results.flatMap((r, i) =>
r.status === "fulfilled"
? r.value
// Hide vibn-dev-* dev containers from this surface — those are
// the AI's own workshop, not part of the user's product.
.filter((s) => !s.name.startsWith("vibn-dev-"))
.map((s) => ({
uuid: s.uuid,
name: s.name,
status: s.status ?? "unknown",
serviceType: s.service_type ?? null,
coolifyProjectUuid: target[i],
}))
: [],
);
return NextResponse.json({ result: services });
}
async function toolServicesGet(
principal: Principal,
params: Record<string, any>,
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const svc = await getServiceInWorkspace(uuid, ownedUuids);
return NextResponse.json({ result: svc });
}
async function toolServicesStart(
principal: Principal,
params: Record<string, any>,
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
await startService(uuid);
return NextResponse.json({ result: { ok: true, uuid, action: "start" } });
}
async function toolServicesStop(
principal: Principal,
params: Record<string, any>,
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
await stopService(uuid);
return NextResponse.json({ result: { ok: true, uuid, action: "stop" } });
}
async function toolServicesEnvsList(
principal: Principal,
params: Record<string, any>,
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
const envs = await listServiceEnvs(uuid);
return NextResponse.json({ result: envs });
}
async function toolServicesEnvsUpsert(
principal: Principal,
params: Record<string, any>,
) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
const key = typeof params.key === "string" ? params.key : "";
const value = typeof params.value === "string" ? params.value : "";
if (!uuid || !key) {
return NextResponse.json(
{ error: 'Params "uuid" and "key" are required' },
{ status: 400 },
);
}
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
const result = await upsertServiceEnv(uuid, {
key,
value,
is_preview: !!params.is_preview,
is_literal: !!params.is_literal,
});
return NextResponse.json({ result });
}
// ──────────────────────────────────────────────────
// Phase 4: apps create/update/delete + domains
// ──────────────────────────────────────────────────
/**
* apps.create — four distinct pathways depending on what you pass:
*
* 1. Gitea repo (for user-owned custom apps)
* Required: repo
* Optional: branch, buildPack, ports, domain, envs, …
*
* 2. Docker image from a registry (no repo, no build)
* Required: image e.g. "nginx:alpine", "twentyhq/twenty:1.23.0"
* Optional: name, domain, ports, envs
*
* 3. Inline Docker Compose YAML (no repo, no build)
* Required: composeRaw (the full docker-compose.yml contents as a string)
* Optional: name, domain, composeDomains, envs
*
* 4. **Coolify one-click template** (RECOMMENDED for popular apps)
* Required: template e.g. "twenty", "n8n", "supabase", "ghost"
* Optional: name, domain, envs
* Discoverable via apps.templates.list / apps.templates.search.
* Coolify ships 320+ vetted templates (CRMs, AI tools, CMSes, etc).
* Each template has battle-tested env defaults, healthchecks, and
* `depends_on` graphs — far more reliable than hand-rolling a
* composeRaw payload for the same app.
*
* Pathway 1 is for code in the workspace's Gitea org. Pathways 2/3/4
* deploy third-party apps without creating a Gitea repo.
*/
async function toolAppsCreate(
principal: Principal,
params: Record<string, any>,
) {
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: "Workspace not fully provisioned (need Coolify project)" },
{ status: 503 },
);
}
// Resolve which Coolify project to deploy into:
// - If params.projectId given, use that Vibn project's per-project Coolify project
// (auto-mint it if not already provisioned).
// - Otherwise fall back to the workspace's legacy Coolify project for back-compat.
let targetCoolifyProjectUuid = ws.coolify_project_uuid;
if (params.projectId) {
const projectId = String(params.projectId);
const projectRow = await queryOne<{ id: string; data: any; slug: string }>(
`SELECT id, data, slug FROM fs_projects
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`,
[projectId, ws.id, ws.slug],
);
if (!projectRow) {
return NextResponse.json(
{ error: `Project ${projectId} not found in this workspace` },
{ status: 404 },
);
}
const projectName =
projectRow.data?.productName || projectRow.data?.name || projectRow.slug;
const ensured = await ensureProjectCoolifyProject(projectId, ws, {
projectSlug: projectRow.slug,
projectName,
});
if (ensured) targetCoolifyProjectUuid = ensured;
}
const commonOpts = {
projectUuid: targetCoolifyProjectUuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isForceHttpsEnabled: true,
instantDeploy: false,
};
// Record the Vibn-project ↔ Coolify-resource link so apps.list { projectId }
// can surface it even when multiple Vibn projects share a Coolify project
// (e.g. legacy workspace project still hosting a hand-deployed n8n alongside
// a project-bound Twenty CRM).
const linkIfRequested = async (
uuid: string,
type: "application" | "service" | "database",
) => {
if (params.projectId) {
try {
await linkResourceToProject(
String(params.projectId),
ws.slug,
uuid,
type,
);
} catch (e) {
console.warn("[mcp apps.create] linkResourceToProject failed", e);
}
}
};
// ── Pathway 4: Coolify one-click template ─────────────────────────────
// Most reliable path for popular third-party apps. Coolify maintains
// a curated catalog at templates/service-templates.json — each entry
// has tested env defaults and a working compose graph.
if (params.template) {
const templateSlug = String(params.template).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_-]*$/.test(templateSlug)) {
return NextResponse.json(
{ error: "Invalid template slug" },
{ status: 400 },
);
}
// Validate slug exists so we fail fast with a useful error rather
// than relaying Coolify's generic "Service not found".
const catalog = await listServiceTemplates();
if (!catalog[templateSlug]) {
return NextResponse.json(
{
error: `Unknown template "${templateSlug}". Use apps.templates.search to find valid slugs.`,
},
{ status: 404 },
);
}
// ── Idempotency: don't fan out duplicate services into the same
// Coolify project. If a service with the same template already
// exists, return it instead of creating a 4th twenty-* clone.
// Use force=true to bypass dedup only when the caller really wants
// multiple instances (e.g. dev/staging copies of the same template).
const force = params.force === true || params.force === "true";
if (params.projectId && !force) {
const existing = await findExistingTemplateService(
targetCoolifyProjectUuid,
templateSlug,
);
if (existing) {
await linkIfRequested(existing.uuid, "service");
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
template: templateSlug,
alreadyExisted: true,
summaryHint:
`A "${templateSlug}" service already exists in this project as "${existing.name}" (uuid ${existing.uuid}). Returning it instead of creating a duplicate. ` +
`If the user wanted a SECOND independent instance, re-call apps_create with { force: true }. ` +
`If the existing one is broken, call apps_unstick { uuid } and then apps_deploy { uuid } — DO NOT delete-and-recreate.`,
},
});
}
}
const appName = slugify(String(params.name ?? templateSlug));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
// Pull the template's required upstream port from the catalog.
// Coolify's "Required Port" UI hint says: domains MUST be specified
// as host:port for the template engine to wire up the right
// SERVICE_FQDN_<APP>_<port> magic env, the loadbalancer.server.port
// Traefik label, and the SERVICE_URL_<APP>_<port> env. Without it
// we get the default sslip.io values everywhere and Traefik returns
// 503 because the routing rules have no port to forward to.
const templatePort = catalog[templateSlug]?.port ?? 3000;
const created = await createService({
projectUuid: commonOpts.projectUuid,
serverUuid: commonOpts.serverUuid,
environmentName: commonOpts.environmentName,
destinationUuid: commonOpts.destinationUuid,
type: templateSlug,
name: appName,
description: params.description ? String(params.description) : undefined,
// Don't ask Coolify to instantly deploy — its queued worker has
// intermittent issues and we want to set the FQDN + envs first.
instantDeploy: false,
});
await linkIfRequested(created.uuid, "service");
// Coolify auto-assigns sslip.io URLs. Replace them with the
// user's FQDN, INCLUDING the required upstream port — see comment
// on `templatePort` above. The :port suffix is what makes Coolify
// generate the loadbalancer.server.port label and substitute the
// SERVICE_FQDN_<APP> env to the user's host (no sslip.io leak).
let urlsApplied = false;
try {
await new Promise((r) => setTimeout(r, 1500));
await setServiceDomains(created.uuid, [
{ name: templateSlug, url: `https://${fqdn}:${templatePort}` },
]);
urlsApplied = true;
} catch (e) {
console.warn("[mcp apps.create/template] setServiceDomains failed", e);
}
// Apply user-provided envs (e.g. POSTGRES_PASSWORD overrides)
if (params.envs && typeof params.envs === "object") {
const envEntries = Object.entries(params.envs as Record<string, unknown>)
.filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k))
.map(([key, value]) => ({ key, value: String(value) }));
for (const env of envEntries) {
try {
await upsertServiceEnv(created.uuid, env);
} catch (e) {
console.warn(
"[mcp apps.create/template] upsert env failed",
env.key,
e,
);
}
}
}
let started = false;
let reachable = false;
let appStatus = "unknown";
let postDeploy: CoolifyPostDeployResult | null = null;
let startDiag = "";
if (params.instantDeploy !== false) {
({
started,
reachable,
appStatus,
postDeploy,
diag: startDiag,
} = await ensureServiceReachable({
uuid: created.uuid,
fqdn,
publicAppName: templateSlug,
port: templatePort,
}));
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
resourceType: "service",
template: templateSlug,
urlsApplied,
started,
reachable,
appStatus,
...(postDeploy ? { postDeploy } : {}),
...(startDiag ? { startDiag } : {}),
note: reachable
? `Reachable on https://${fqdn}. First boot may continue migrations in the background — check apps.logs if any feature seems missing.`
: started
? `Containers are healthy but https://${fqdn} did not return 2xx/3xx yet. Wait 30-60s for Traefik to fully discover labels, then retry. If still failing, inspect postDeploy.steps for which fix didn't apply, then call apps.logs and apps.containers.ps.`
: `Public app did not become healthy. Use apps.containers.ps and apps.logs to diagnose. Most common cause: image pull is still in progress (first deploy can take 5-10 min for large images like twentycrm/twenty).`,
},
});
}
// ── Pathway 2: Docker image ───────────────────────────────────────────
if (params.image) {
const image = String(params.image).trim();
const appName = slugify(
String(params.name ?? image.split("/").pop()?.split(":")[0] ?? "app"),
);
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (
params.projectId &&
!(params.force === true || params.force === "true")
) {
const existing = await findExistingResourceByName(
targetCoolifyProjectUuid,
appName,
);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, "image"),
},
});
}
}
const created = await createDockerImageApp({
...commonOpts,
image,
name: appName,
portsExposes: String(params.ports ?? "80"),
domains: toDomainsString([fqdn]),
description: params.description ? String(params.description) : undefined,
});
await linkIfRequested(created.uuid, "application");
await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
},
});
}
// ── Pathway 3: Inline Docker Compose (creates a Coolify Service) ────
if (params.composeRaw) {
const composeRaw = String(params.composeRaw).trim();
const appName = slugify(String(params.name ?? "app"));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (
params.projectId &&
!(params.force === true || params.force === "true")
) {
const existing = await findExistingResourceByName(
targetCoolifyProjectUuid,
appName,
);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, "composeRaw"),
},
});
}
}
const created = await createDockerComposeApp({
...commonOpts,
composeRaw,
name: appName,
description: params.description ? String(params.description) : undefined,
});
await linkIfRequested(created.uuid, "service");
// Services use /services/{uuid}/envs — upsert each env var
if (params.envs && typeof params.envs === "object") {
const envEntries = Object.entries(params.envs as Record<string, unknown>)
.filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k))
.map(([key, value]) => ({ key, value: String(value) }));
if (envEntries.length > 0) {
try {
// Wait briefly for Coolify to commit the service to DB
await new Promise((r) => setTimeout(r, 2000));
for (const env of envEntries) {
await upsertServiceEnv(created.uuid, env);
}
} catch (e) {
console.warn(
"[mcp apps.create/composeRaw] upsert service env failed",
e,
);
}
}
}
// composeRaw is user-supplied — we can't reliably guess the public
// app name (the user may have any compose service layout). Best
// effort: use the app name as the public app name, which works for
// single-container composes.
let started = false;
let reachable = false;
let appStatus = "unknown";
let postDeploy: CoolifyPostDeployResult | null = null;
let startDiag = "";
if (params.instantDeploy !== false) {
const publicAppName = String(params.publicAppName ?? appName);
({
started,
reachable,
appStatus,
postDeploy,
diag: startDiag,
} = await ensureServiceReachable({
uuid: created.uuid,
fqdn,
publicAppName,
port: params.port ? Number(params.port) : undefined,
}));
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
resourceType: "service",
started,
reachable,
appStatus,
...(postDeploy ? { postDeploy } : {}),
...(startDiag ? { startDiag } : {}),
note: reachable
? `Reachable on https://${fqdn}.`
: `Domain routing for custom compose services depends on knowing which docker-compose service is the public-facing one. Pass publicAppName=<service> and port=<port> on apps.create to enable post-deploy patching, or set them manually.`,
},
});
}
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
if (!ws.gitea_org) {
return NextResponse.json(
{
error:
"Workspace not fully provisioned (need Gitea org). For third-party apps, use `template` (recommended), `image`, or `composeRaw` instead of `repo`.",
},
{ status: 503 },
);
}
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
{
error:
"Workspace Gitea bot credentials unavailable — re-run provisioning",
},
{ status: 503 },
);
}
const repoIn = String(params.repo ?? "").trim();
if (!repoIn) {
return NextResponse.json(
{ error: "One of `repo`, `image`, or `composeRaw` is required" },
{ status: 400 },
);
}
const parts = repoIn.replace(/\.git$/, "").split("/");
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
const repoName = parts.length === 2 ? parts[1] : parts[0];
if (repoOrg !== ws.gitea_org) {
return NextResponse.json(
{
error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}`,
},
{ status: 403 },
);
}
const repo = await getRepo(repoOrg, repoName);
if (!repo) {
return NextResponse.json(
{ error: `Repo ${repoOrg}/${repoName} not found in Gitea` },
{ status: 404 },
);
}
const appName = slugify(String(params.name ?? repoName));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (params.projectId && !(params.force === true || params.force === "true")) {
const existing = await findExistingResourceByName(
targetCoolifyProjectUuid,
appName,
);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, "repo"),
},
});
}
}
// Fall back to creating a private app with a deploy key if available
const privateKeyUuid = ws.coolify_private_key_uuid;
let created;
if (privateKeyUuid) {
created = await createPrivateDeployKeyApp({
...commonOpts,
privateKeyUuid,
gitRepository: `git@git.vibnai.com:222/${repoOrg}/${repoName}.git`,
gitBranch: String(params.branch ?? repo.default_branch ?? "main"),
portsExposes: String(params.ports ?? "3000"),
buildPack: (params.buildPack as any) ?? "nixpacks",
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
instantDeploy: false,
installCommand: params.installCommand
? String(params.installCommand)
: undefined,
buildCommand: params.buildCommand
? String(params.buildCommand)
: undefined,
startCommand: params.startCommand
? String(params.startCommand)
: undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
dockerfileLocation: params.dockerfileLocation
? String(params.dockerfileLocation)
: undefined,
baseDirectory: params.baseDirectory
? String(params.baseDirectory)
: undefined,
});
} else {
// If no deploy key is configured for this workspace, fall back to public app
// with embedded basic-auth credentials (often fails on newer Coolify versions due to strict cloning)
created = await createPublicApp({
...commonOpts,
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",
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
instantDeploy: false,
installCommand: params.installCommand
? String(params.installCommand)
: undefined,
buildCommand: params.buildCommand
? String(params.buildCommand)
: undefined,
startCommand: params.startCommand
? String(params.startCommand)
: undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
dockerfileLocation: params.dockerfileLocation
? String(params.dockerfileLocation)
: undefined,
baseDirectory: params.baseDirectory
? String(params.baseDirectory)
: undefined,
});
}
await linkIfRequested(created.uuid, "application");
const dep = await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
deploymentUuid: dep,
},
});
}
// ──────────────────────────────────────────────────
// apps.containers.* — direct lifecycle for compose stacks
// ──────────────────────────────────────────────────
//
// These bypass Coolify's queued-start worker (which is unreliable for
// compose Services) and run `docker compose up -d` / `ps` against the
// rendered compose dir on the Coolify host. Used as the recovery
// path when Coolify's start API returns "queued" but no containers
// materialise.
//
// 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
* caller's project. Returns the canonical resource kind for
* coolify-compose helpers. NextResponse on policy error / not found. */
async function resolveAppOrService(
principal: Principal,
uuid: string,
): Promise<{ uuid: string; kind: ResourceKind } | NextResponse> {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
try {
await getApplicationInWorkspace(uuid, ownedUuids);
return { uuid, kind: "application" };
} catch (e) {
if (!(e instanceof Error && /404|not found/i.test(e.message))) {
// Tenant errors and other unexpected ones — surface them
if (e instanceof TenantError)
return NextResponse.json({ error: e.message }, { status: 403 });
throw e;
}
}
try {
await getServiceInWorkspace(uuid, ownedUuids);
return { uuid, kind: "service" };
} catch (e) {
if (e instanceof TenantError) {
return NextResponse.json({ error: e.message }, { status: 403 });
}
return NextResponse.json(
{ error: `App or service ${uuid} not found in this workspace` },
{ status: 404 },
);
}
}
/**
* apps.containers.up — `docker compose up -d` against the rendered
* compose dir on the Coolify host.
*
* Use when Coolify's queued-start left the stack in "Created" or
* "no containers" state, or after editing env vars / domains to
* apply the changes (compose env file is regenerated; containers
* need to be recreated to pick it up).
*
* Idempotent — already-running containers are no-op'd. Returns
* `{ ok, code, stdout, stderr, durationMs }` so agents can show the
* user what happened.
*/
async function toolAppsContainersUp(
principal: Principal,
params: Record<string, any>,
) {
const uuid = String(params.uuid ?? params.appUuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{ error: "apps.containers.up requires SSH to the Coolify host" },
{ status: 501 },
);
}
const resolved = await resolveAppOrService(principal, uuid);
if (resolved instanceof NextResponse) return resolved;
const t0 = Date.now();
const r = await composeUp(resolved.kind, resolved.uuid, {
timeoutMs: 600_000,
});
return NextResponse.json({
result: {
ok: r.code === 0,
code: r.code,
stdout: r.stdout.slice(-4000),
stderr: r.stderr.slice(-4000),
truncated: r.truncated,
durationMs: Date.now() - t0,
},
});
}
/**
* apps.containers.ps — `docker compose ps -a` for diagnostics.
*
* Returns a one-line-per-container summary including names, image,
* state, and exit codes. Use to check whether containers are stuck
* in `Created` (Coolify queued-start failure) vs `Exited` (app crash)
* vs `Restarting` (boot loop).
*/
async function toolAppsContainersPs(
principal: Principal,
params: Record<string, any>,
) {
const uuid = String(params.uuid ?? params.appUuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{ error: "apps.containers.ps requires SSH to the Coolify host" },
{ status: 501 },
);
}
const resolved = await resolveAppOrService(principal, uuid);
if (resolved instanceof NextResponse) return resolved;
const r = await composePs(resolved.kind, resolved.uuid);
return NextResponse.json({
result: {
ok: r.code === 0,
stdout: r.stdout.slice(-4000),
stderr: r.stderr.slice(-2000),
},
});
}
/**
* apps.repair — re-run post-deploy patches against an existing service.
*
* Use this when a service is running but unreachable on its custom
* domain (typical Traefik 503 / Mixed Content symptoms). It applies
* the same three fixes apps.create runs on a fresh deploy:
*
* 1. Rewrite SERVICE_FQDN_* / SERVICE_URL_* in the service .env so
* Coolify regen no longer overwrites them with sslip.io defaults.
* 2. Inject the missing traefik.http.services.<svc>.loadbalancer.
* server.port label into docker-compose.yml.
* 3. Connect coolify-proxy to the service's project network.
* 4. Force-recreate the public-facing app container.
* 5. Restart coolify-proxy so Traefik re-discovers labels.
*
* Params:
* uuid required — service uuid (the resource, not a single container)
* fqdn required — the public hostname (e.g. "crm.mark.vibnai.com")
* publicAppName required — docker-compose service name of the public app
* (usually equals the template slug: "twenty", "n8n", …)
* port optional — internal port (default: derived per template)
*
* Returns the same { ok, steps } shape as the post-deploy block in
* apps.create plus a final reachability probe.
*/
async function toolAppsRepair(
_principal: Principal,
params: Record<string, any>,
) {
const uuid = String(params.uuid ?? "").trim();
const fqdn = String(params.fqdn ?? "").trim();
const publicAppName = String(params.publicAppName ?? "").trim();
const port = params.port != null ? Number(params.port) : undefined;
if (!uuid || !fqdn || !publicAppName) {
return NextResponse.json(
{ error: "apps.repair requires { uuid, fqdn, publicAppName }" },
{ status: 400 },
);
}
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
"apps.repair requires SSH to the Coolify host (set COOLIFY_SSH_*)",
},
{ status: 501 },
);
}
const postDeploy = await applyCoolifyPostDeployFixes({
uuid,
fqdn,
publicAppName,
port,
});
let reachable = false;
let probeDiag = "";
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12_000);
const res = await fetch(`https://${fqdn}`, {
signal: ctrl.signal,
redirect: "manual",
});
clearTimeout(t);
reachable = res.status >= 200 && res.status < 400;
probeDiag = `GET https://${fqdn}${res.status}`;
} catch (e) {
probeDiag = `probe failed: ${e instanceof Error ? e.message : String(e)}`;
}
return NextResponse.json({
result: {
uuid,
fqdn,
publicAppName,
reachable,
postDeploy,
probe: probeDiag,
note: reachable
? `Repaired and reachable on https://${fqdn}.`
: `Repair steps applied but probe still failed. Check postDeploy.steps for any "ok: false" entries; otherwise wait 30s and retry the probe.`,
},
});
}
// ──────────────────────────────────────────────────
// apps.templates.* — Coolify one-click catalog browse
// ──────────────────────────────────────────────────
//
// Coolify ships ~320 vetted service templates (CRMs, AI, CMS, etc).
// These tools let agents discover what's available so they can pass
// the right slug to apps.create({ template: "..." }).
/**
* apps.templates.list — paginate the full catalog.
*
* Params:
* limit number, default 50, max 500
* offset number, default 0
* tag string, optional — restrict to templates whose tags include this substring
*
* Result: { total, items: CoolifyServiceTemplate[] }
*
* The catalog is large (~320 entries), so use apps.templates.search
* when you know what you're looking for.
*/
async function toolAppsTemplatesList(params: Record<string, any>) {
const all = await listServiceTemplates();
const tagFilter = params.tag ? String(params.tag).trim().toLowerCase() : "";
const limit = Math.max(1, Math.min(Number(params.limit ?? 50) || 50, 500));
const offset = Math.max(0, Number(params.offset ?? 0) || 0);
let entries = Object.values(all);
if (tagFilter) {
entries = entries.filter((t) =>
(t.tags ?? []).some((x) => x.toLowerCase().includes(tagFilter)),
);
}
entries.sort((a, b) => a.slug.localeCompare(b.slug));
return NextResponse.json({
result: {
total: entries.length,
offset,
limit,
items: entries.slice(offset, offset + limit),
},
});
}
/**
* apps.templates.search — find templates by name, tag, or slogan.
*
* Params:
* query string (required) — case-insensitive substring; matches
* slug > tag > slogan in priority order
* tag string, optional — additional tag filter
* limit number, default 25, max 100
*
* Result: { items: CoolifyServiceTemplate[] }
*
* Examples:
* { query: "twenty" } → [{ slug: "twenty", ... }]
* { query: "wordpress" } → 4 wordpress variants
* { query: "", tag: "crm" } → all CRM templates
* { query: "ai", tag: "vector" } → vector DBs
*/
async function toolAppsTemplatesSearch(params: Record<string, any>) {
const query = String(params.query ?? "").trim();
const tag = params.tag ? String(params.tag).trim() : undefined;
if (!query && !tag) {
return NextResponse.json(
{ error: "Either `query` or `tag` is required" },
{ status: 400 },
);
}
const limit = Math.max(1, Math.min(Number(params.limit ?? 25) || 25, 100));
const items = await searchServiceTemplates(query, { tag, limit });
return NextResponse.json({ result: { items } });
}
/**
* Bring a Coolify Service to a publicly-reachable state.
*
* v2.4.5 architecture
* --------------------
* Earlier versions ran `docker compose up -d` over SSH as a fallback
* when Coolify's queue stalled. That worked for "containers running"
* but caused two cascading bugs because it bypassed Coolify's full
* deploy pipeline:
* - Internal services (Postgres, Redis) ended up on the shared
* `coolify` Docker network, where DNS aliases for `postgres`/
* `redis` collide with Coolify's own `coolify-db`/`coolify-redis`
* containers — Twenty's `postgres://postgres:5432/twenty-db`
* resolves to the wrong DB and fails auth.
* - The proxy-network attach we did in our SSH path attached EVERY
* container, magnifying the same DNS collision.
*
* The right model is: let Coolify's queue do the heavy lifting (it
* handles compose generation, volumes, internal networking, env-var
* substitution, healthchecks, etc.) and patch the three things its
* REST API does NOT expose:
* 1. SERVICE_FQDN_* / SERVICE_URL_* env vars in the rendered .env
* 2. The missing traefik loadbalancer.server.port label
* 3. coolify-proxy → project network attachment + Traefik nudge
*
* Steps:
* 1. POST /services/{uuid}/start — Coolify's queue does its thing.
* 2. Poll service.applications[*].status (the per-application
* status is truthful; service.status is not). Wait until the
* public app reports running:healthy or we time out.
* 3. apply post-deploy fixes: rewrite .env, inject port label,
* attach proxy to project net, recreate ONLY the public app,
* restart proxy so Traefik re-discovers.
* 4. (Optional) probe https://<fqdn> for a 200/301/302 to confirm
* end-to-end reachability.
*/
async function ensureServiceReachable(opts: {
uuid: string;
fqdn: string;
publicAppName: string;
port?: number;
/** Max wall-clock time to wait for Coolify to bring containers healthy. */
healthTimeoutMs?: number;
}): Promise<{
started: boolean;
reachable: boolean;
appStatus: string;
postDeploy: CoolifyPostDeployResult | null;
diag: string;
}> {
const {
uuid,
fqdn,
publicAppName,
port,
healthTimeoutMs = 8 * 60_000,
} = opts;
try {
await startService(uuid);
} catch (e) {
console.warn("[ensureServiceReachable] startService failed", e);
}
// Poll service.applications[*].status until the public app is
// running:healthy. This field is truthful, unlike service.status
// which routinely lies as "starting:unknown" while containers are
// actually healthy.
// Coolify's queue worker can take 60-120s to dequeue a start
// request, during which time service.applications[*].status still
// reports the stale `exited` state (= "never started"). We only
// treat `exited` as terminal AFTER we've seen evidence of activity
// (`starting:*` or `running:*`) — otherwise it's just queue lag.
const startedAt = Date.now();
let appStatus = "unknown";
let sawActivity = false;
let lastExitObservedAt = 0;
while (Date.now() - startedAt < healthTimeoutMs) {
try {
const svc = (await getService(uuid)) as unknown as {
applications?: Array<{ name?: string; status?: string }>;
};
const apps = svc.applications ?? [];
const target = apps.find((a) => a.name === publicAppName) ?? apps[0];
appStatus = target?.status ?? "unknown";
if (/^running:healthy/i.test(appStatus)) break;
if (/^starting|^running/i.test(appStatus)) {
sawActivity = true;
lastExitObservedAt = 0;
}
// Once we've seen activity, an exited status is terminal —
// boot loop or compose failure. Wait 30s of consecutive
// `exited` to be sure it's not a Compose recreate cycle.
if (sawActivity && /^exited/i.test(appStatus)) {
if (lastExitObservedAt === 0) lastExitObservedAt = Date.now();
if (Date.now() - lastExitObservedAt > 30_000) break;
} else if (!/^exited/i.test(appStatus)) {
lastExitObservedAt = 0;
}
} catch (e) {
console.warn("[ensureServiceReachable] status probe failed", e);
}
await new Promise((r) => setTimeout(r, 8_000));
}
const started = /^running/i.test(appStatus);
if (!started) {
return {
started: false,
reachable: false,
appStatus,
postDeploy: null,
diag: `Public app "${publicAppName}" did not become healthy within ${Math.round(healthTimeoutMs / 1000)}s (status=${appStatus}). Use apps.containers.ps and apps.logs to diagnose.`,
};
}
// Apply post-deploy fixes. Only meaningful when SSH is configured —
// without it we can't rewrite the .env or attach proxy networks.
let postDeploy: CoolifyPostDeployResult | null = null;
if (isCoolifySshConfigured()) {
try {
postDeploy = await applyCoolifyPostDeployFixes({
uuid,
fqdn,
publicAppName,
port,
});
} catch (e) {
console.warn("[ensureServiceReachable] post-deploy fix failed", e);
}
}
// Best-effort reachability probe. Public DNS for the workspace
// wildcard may not have propagated yet (esp. on first deploy in a
// brand-new workspace), so a non-200 here doesn't mean failure —
// it just means "agents should retry the URL in a few seconds".
let reachable = false;
let probeDiag = "";
try {
const url = `https://${fqdn}`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12_000);
const res = await fetch(url, { signal: ctrl.signal, redirect: "manual" });
clearTimeout(t);
reachable = res.status >= 200 && res.status < 400;
probeDiag = `GET ${url}${res.status}`;
} catch (e) {
probeDiag = `GET probe failed: ${e instanceof Error ? e.message : String(e)}`;
}
return {
started: true,
reachable,
appStatus,
postDeploy,
diag: probeDiag,
};
}
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
function resolveFqdn(
domainParam: unknown,
slug: string,
appName: string,
): string | NextResponse {
const fqdn = String(domainParam ?? "").trim()
? String(domainParam).replace(/^https?:\/\//, "")
: workspaceAppFqdn(slug, appName);
if (!isDomainUnderWorkspace(fqdn, slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with -${slug}.vibnai.com` },
{ status: 403 },
);
}
return fqdn;
}
/** Upsert envs then optionally trigger deploy. Returns deploymentUuid or null. */
async function applyEnvsAndDeploy(
appUuid: string,
params: Record<string, any>,
): Promise<string | null> {
if (params.envs && typeof params.envs === "object") {
for (const [k, v] of Object.entries(
params.envs as Record<string, unknown>,
)) {
if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
try {
await upsertApplicationEnv(appUuid, { key: k, value: String(v) });
} catch (e) {
console.warn("[mcp apps.create] upsert env failed", k, e);
}
}
}
// Sentry-as-product: when this app belongs to a Vibn project,
// ensure a Sentry project exists for it and inject the DSN +
// shared org auth token as Coolify env vars. Done here (after
// user envs, before deploy) so the very first build of the app
// already inlines the public DSN into the client bundle and
// uploads source maps. See SENTRY_AS_PRODUCT.md.
if (params.projectId) {
try {
const projectId = String(params.projectId);
const projectRow = await queryOne<{
slug: string;
data: any;
workspace: string;
}>(
`SELECT slug, data, workspace FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId],
);
if (projectRow) {
await ensureSentryProject({
projectId,
workspaceSlug: projectRow.workspace,
projectSlug: projectRow.slug,
projectName:
projectRow.data?.productName ||
projectRow.data?.name ||
projectRow.slug,
});
await applySentryEnvToCoolifyApp(appUuid, projectId);
}
} catch (e) {
console.warn(
"[mcp apps.create] sentry provisioning failed (non-fatal)",
e,
);
}
}
if (params.instantDeploy === false) return null;
try {
// Give Coolify 1.5s to settle the DB inserts and attach the deploy key before hitting /deploy
await new Promise((r) => setTimeout(r, 1500));
const dep = await deployApplication(appUuid);
return dep.deployment_uuid ?? null;
} catch (e) {
console.warn("[mcp apps.create] first deploy failed", e);
return null;
}
}
async function toolAppsUpdate(
principal: Principal,
params: Record<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const allowed = new Set([
"name",
"description",
"git_branch",
"git_commit_sha",
"build_pack",
"ports_exposes",
"install_command",
"build_command",
"start_command",
"base_directory",
"dockerfile_location",
"docker_compose_location",
"is_auto_deploy_enabled",
"is_force_https_enabled",
"static_image",
]);
// ── Control params (never forwarded to Coolify) ─────────────────────
// `uuid`/`appUuid` identify the target; we've consumed them already.
const control = new Set(["uuid", "appUuid", "patch"]);
// ── Fields we deliberately DO NOT forward from apps.update ─────────
// Each maps to a different tool; silently dropping them the way we
// used to caused real live-test bugs (PATCH returns ok, nothing
// persists, agent thinks it worked).
const redirected: Record<string, string> = {
fqdn: "apps.domains.set",
domains: "apps.domains.set",
docker_compose_domains: "apps.domains.set",
git_repository: "apps.rewire_git",
};
// Support both the flat `{ uuid, name, description, ... }` shape and
// the explicit `{ uuid, patch: { name, description, ... } }` shape.
const source: Record<string, unknown> =
params.patch &&
typeof params.patch === "object" &&
!Array.isArray(params.patch)
? (params.patch as Record<string, unknown>)
: params;
const patch: Record<string, unknown> = {};
const ignored: string[] = [];
const rerouted: Array<{ field: string; use: string }> = [];
for (const [k, v] of Object.entries(source)) {
if (v === undefined) continue;
if (control.has(k) && source === params) continue;
if (redirected[k]) {
rerouted.push({ field: k, use: redirected[k] });
continue;
}
if (allowed.has(k)) {
patch[k] = v;
continue;
}
ignored.push(k);
}
if (Object.keys(patch).length === 0) {
return NextResponse.json(
{
error: rerouted.length
? "No updatable fields in params. Some fields must be set via other tools — see `rerouted`."
: "No updatable fields in params. See `ignored` and `allowed`.",
rerouted,
ignored,
allowed: [...allowed],
},
{ status: 400 },
);
}
await updateApplication(appUuid, patch);
return NextResponse.json({
result: {
ok: true,
uuid: appUuid,
applied: Object.keys(patch),
// Non-empty `ignored`/`rerouted` are NOT errors but callers need to
// see them; silently dropping unrecognised keys was the original
// "fqdn returns ok but doesn't persist" false-positive.
...(ignored.length ? { ignored } : {}),
...(rerouted.length ? { rerouted } : {}),
},
});
}
/**
* 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<string, any>,
) {
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 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 getApplicationInWorkspace(appUuid, ownedUuids);
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<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const confirm = String(params.confirm ?? "");
if (confirm !== app.name) {
return NextResponse.json(
{
error: "Confirmation required",
hint: `Pass confirm=${app.name} to delete`,
},
{ status: 409 },
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteApplication(appUuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
await unlinkResource(appUuid).catch((e) =>
console.warn("[mcp apps.delete] unlink failed", e),
);
return NextResponse.json({
result: {
ok: true,
deleted: { uuid: appUuid, name: app.name, volumesKept: !deleteVolumes },
},
});
}
async function toolAppsDomainsList(
principal: Principal,
params: Record<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const raw = (app.domains ?? app.fqdn ?? "") as string;
const list = raw
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/^https?:\/\//, "").replace(/\/+$/, ""));
return NextResponse.json({ result: { uuid: appUuid, domains: list } });
}
async function toolAppsDomainsSet(
principal: Principal,
params: Record<string, any>,
) {
const ws = principal.workspace;
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 domainsIn = Array.isArray(params.domains) ? params.domains : [];
if (!appUuid || domainsIn.length === 0) {
return NextResponse.json(
{ error: 'Params "uuid" and "domains[]" are required' },
{ status: 400 },
);
}
const normalized: string[] = [];
for (const d of domainsIn) {
if (typeof d !== "string" || !d.trim()) continue;
const clean = d
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "")
.toLowerCase();
if (!isDomainUnderWorkspace(clean, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${clean} must end with -${ws.slug}.vibnai.com` },
{ status: 403 },
);
}
normalized.push(clean);
}
// Try service first — services (template-based, e.g. Twenty CRM) have
// a totally different domain mechanism than plain applications. The
// ServiceApplication.fqdn field is the source of truth, but writing
// it directly without invoking Coolify's updateCompose() + parse()
// pipeline gets reverted on next deploy. Replicate the Livewire
// EditDomain.php save flow via tinker so Traefik labels actually
// regenerate.
const composeService =
typeof params.service === "string" && params.service.trim()
? params.service.trim()
: typeof params.composeService === "string" &&
params.composeService.trim()
? params.composeService.trim()
: undefined;
let serviceFound = false;
try {
const svc = await getServiceInWorkspace(appUuid, principal.workspace);
if (svc) serviceFound = true;
} catch {}
if (serviceFound) {
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
"Setting custom domains on services requires SSH-managed Coolify (not configured on this deploy).",
},
{ status: 503 },
);
}
// Pick the inner app to wire the domain to. Default = first
// non-worker / non-job app, mirroring Coolify UI defaults.
const inner = composeService ?? "auto";
// Ensure the user-provided FQDN includes a port — Coolify hard-fails
// the save if a template exposes a required port and we don't pin it.
// Default to the template's required port when known.
const port = params.port ? Number(params.port) : null;
const fqdnsForUrl = normalized
.map((d) => `https://${d}${port ? `:${port}` : ""}`)
.join(",");
// PHP code: load service, find target inner app, save fqdn, then
// INLINE-REPLACE ${SERVICE_URL_<NAME>} / ${SERVICE_FQDN_<NAME>}
// interpolations in docker_compose_raw with the literal user-provided
// URL. This bypasses Coolify's parse() — which iterates ALL apps in
// a multi-container template (twenty + worker + redis + postgres)
// and overwrites SERVICE_URL_<NAME> env vars based on whichever
// ServiceApplication.fqdn it sees first, often producing the
// *.sslip.io fallback. Hardcoding the URL into the compose template
// makes the deploy survive future parse() calls (which the deploy
// job runs internally on every redeploy).
const phpCode = `
$service = App\\Models\\Service::where('uuid', '${appUuid}')->first();
if (!$service) { echo 'service-not-found'; exit; }
$apps = $service->applications()->get();
$target = ${inner === "auto" ? "$apps->first(fn($a) => !str_contains(strtolower($a->name), 'worker') && !str_contains(strtolower($a->name), 'job')) ?? $apps->first()" : `$apps->firstWhere('name', '${inner}')`};
if (!$target) { echo 'inner-app-not-found'; exit; }
$target->fqdn = '${fqdnsForUrl}';
$target->save();
// Parse the saved fqdn into base URL + bare host so we can substitute.
$first = explode(',', $target->fqdn)[0];
preg_match('#^(https?://)([^:/]+)(?::(\\\\d+))?#', $first, $mm);
$scheme = $mm[1] ?? 'https://';
$host = $mm[2] ?? '';
$urlBase = $scheme . $host;
$fqdnBase = $host;
// Compose template uses the inner app NAME uppercased, dashes→underscores.
$svcVar = strtoupper(str_replace('-', '_', $target->name));
$raw = $service->docker_compose_raw ?? '';
// Replace port-specific first so the bare match below doesn't eat the prefix.
$raw = preg_replace_callback(
'/\\\\$\\\\{SERVICE_URL_' . preg_quote($svcVar, '/') . '_(\\\\d+)\\\\}/',
fn($m) => $urlBase . ':' . $m[1],
$raw
);
$raw = preg_replace_callback(
'/\\\\$\\\\{SERVICE_FQDN_' . preg_quote($svcVar, '/') . '_(\\\\d+)\\\\}/',
fn($m) => $fqdnBase . ':' . $m[1],
$raw
);
$raw = str_replace('\\\${SERVICE_URL_' . $svcVar . '}', $urlBase, $raw);
$raw = str_replace('\\\${SERVICE_FQDN_' . $svcVar . '}', $fqdnBase, $raw);
$service->docker_compose_raw = $raw;
$service->save();
// updateCompose() rewrites the rendered docker_compose output and the
// SERVICE_URL_/SERVICE_FQDN_ env vars from $target->fqdn. We deliberately
// SKIP $service->parse() because it picks the wrong inner app in
// multi-container templates and reverts env vars to sslip.io.
updateCompose($target);
echo 'fqdn-saved=' . $target->fresh()->fqdn;
`;
const result = await runOnCoolifyHost(
`docker exec coolify php artisan tinker --execute=${shellEscape(phpCode)}`,
);
const out = (result.stdout || "").trim();
if (
out.includes("service-not-found") ||
out.includes("inner-app-not-found")
) {
return NextResponse.json(
{
error: out,
stderr: result.stderr,
},
{ status: 404 },
);
}
return NextResponse.json({
result: {
uuid: appUuid,
kind: "service",
domains: normalized,
innerApp: inner,
tinkerOut: out,
summaryHint: `Custom domain saved on service ${appUuid}. Now call apps_deploy { uuid: "${appUuid}" } to regenerate Traefik labels and bring the new domain live.`,
},
});
}
const app = await getApplicationInWorkspace(appUuid, ownedUuids);
const buildPack = (app.build_pack ?? "nixpacks") as string;
await setApplicationDomains(appUuid, normalized, {
forceOverride: true,
buildPack,
composeService,
});
return NextResponse.json({
result: {
uuid: appUuid,
kind: "application",
domains: normalized,
buildPack,
routedTo:
buildPack === "dockercompose"
? {
field: "docker_compose_domains",
service: composeService ?? "server",
}
: { field: "domains" },
},
});
}
// Minimal POSIX-shell single-quote escape for arbitrary string content.
// PHP code can contain any character except a literal `'`; we wrap in
// single quotes and break out for embedded singles.
function shellEscape(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
// ──────────────────────────────────────────────────
// Phase 4: databases
// ──────────────────────────────────────────────────
const DB_TYPES: readonly CoolifyDatabaseType[] = [
"postgresql",
"mysql",
"mariadb",
"mongodb",
"redis",
"keydb",
"dragonfly",
"clickhouse",
];
async function toolWorkspaceDbQuery(
principal: Principal,
params: Record<string, unknown>
) {
const { sql } = params;
if (typeof sql !== "string") {
return NextResponse.json({ error: "Missing 'sql' string parameter" }, { status: 400 });
}
// Safety check: Prevent modifying the database
const upperSql = sql.toUpperCase().trim();
if (
upperSql.startsWith("INSERT") ||
upperSql.startsWith("UPDATE") ||
upperSql.startsWith("DELETE") ||
upperSql.startsWith("DROP") ||
upperSql.startsWith("ALTER") ||
upperSql.startsWith("TRUNCATE") ||
upperSql.startsWith("GRANT") ||
upperSql.startsWith("REVOKE")
) {
return NextResponse.json({ error: "Only SELECT queries are allowed." }, { status: 403 });
}
try {
const result = await query(sql, []);
return NextResponse.json({ result: result.slice(0, 100) }); // Limit to 100 rows for safety
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
async function toolDatabasesList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const dbs = await listDatabasesInProject(projectUuid);
return NextResponse.json({
result: dbs.map((d) => ({
uuid: d.uuid,
name: d.name,
type: d.type ?? null,
status: d.status,
isPublic: d.is_public ?? false,
publicPort: d.public_port ?? null,
})),
});
}
async function toolDatabasesCreate(
principal: Principal,
params: Record<string, any>,
) {
const ws = principal.workspace;
let projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
// Scope to the Vibn project's dedicated Coolify project when projectId
// is supplied (auto-mint if needed). This both narrows the dedup scope
// and prevents cross-project leaks.
if (params.projectId) {
const projectId = String(params.projectId);
const projectRow = await queryOne<{ id: string; data: any; slug: string }>(
`SELECT id, data, slug FROM fs_projects
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`,
[projectId, ws.id, ws.slug],
);
if (!projectRow) {
return NextResponse.json(
{ error: `Project ${projectId} not found in this workspace` },
{ status: 404 },
);
}
const projectName =
projectRow.data?.productName || projectRow.data?.name || projectRow.slug;
const ensured = await ensureProjectCoolifyProject(projectId, ws, {
projectSlug: projectRow.slug,
projectName,
});
if (ensured) projectUuid = ensured;
}
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const type = String(params.type ?? "").toLowerCase() as CoolifyDatabaseType;
if (!DB_TYPES.includes(type)) {
return NextResponse.json(
{ error: `Param "type" must be one of: ${DB_TYPES.join(", ")}` },
{ status: 400 },
);
}
const name = slugify(
String(params.name ?? `${type}-${Date.now().toString(36)}`),
);
// Idempotency: when a database with the same name already exists in
// this Coolify project, return it instead of creating a duplicate.
// The AI fans out duplicate dbs the same way it fans out apps when
// its first attempt has issues — same fix.
if (params.projectId && !(params.force === true || params.force === "true")) {
const existing = await findExistingResourceByName(
projectUuid as string,
name,
);
if (existing) {
if (params.projectId) {
try {
await linkResourceToProject(
String(params.projectId),
ws.slug,
existing.uuid,
existing.type,
);
} catch {}
}
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, "databases.create"),
},
});
}
}
const { uuid } = await createDatabase({
type,
name,
description: params.description ? String(params.description) : undefined,
projectUuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isPublic: params.isPublic === true,
publicPort:
typeof params.publicPort === "number" ? params.publicPort : undefined,
image: params.image ? String(params.image) : undefined,
credentials:
params.credentials && typeof params.credentials === "object"
? params.credentials
: {},
limits:
params.limits && typeof params.limits === "object"
? params.limits
: undefined,
instantDeploy: params.instantDeploy !== false,
});
const db = await getDatabaseInWorkspace(uuid, ownedUuids);
if (params.projectId) {
try {
await linkResourceToProject(
String(params.projectId),
ws.slug,
uuid,
"database",
);
} catch (e) {
console.warn("[mcp databases.create] linkResourceToProject failed", e);
}
}
return NextResponse.json({
result: {
uuid: db.uuid,
name: db.name,
type: db.type ?? type,
status: db.status,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
},
});
}
async function toolDatabasesGet(
principal: Principal,
params: Record<string, any>,
) {
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 getDatabaseInWorkspace(uuid, ownedUuids);
return NextResponse.json({
result: {
uuid: db.uuid,
name: db.name,
type: db.type ?? null,
status: db.status,
isPublic: db.is_public ?? false,
publicPort: db.public_port ?? null,
internalUrl: db.internal_db_url ?? null,
externalUrl: db.external_db_url ?? null,
},
});
}
async function toolDatabasesUpdate(
principal: Principal,
params: Record<string, any>,
) {
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 getDatabaseInWorkspace(uuid, ownedUuids);
const allowed = new Set([
"name",
"description",
"is_public",
"public_port",
"image",
"limits_memory",
"limits_cpus",
]);
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
if (allowed.has(k) && v !== undefined) patch[k] = v;
}
if (Object.keys(patch).length === 0) {
return NextResponse.json(
{ error: "No updatable fields in params" },
{ status: 400 },
);
}
await updateDatabase(uuid, patch);
return NextResponse.json({ result: { ok: true, uuid } });
}
async function toolDatabasesDelete(
principal: Principal,
params: Record<string, any>,
) {
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 getDatabaseInWorkspace(uuid, ownedUuids);
const confirm = String(params.confirm ?? "");
if (confirm !== db.name) {
return NextResponse.json(
{
error: "Confirmation required",
hint: `Pass confirm=${db.name} to delete`,
},
{ status: 409 },
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteDatabase(uuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
await unlinkResource(uuid).catch((e) =>
console.warn("[mcp databases.delete] unlink failed", e),
);
return NextResponse.json({
result: {
ok: true,
deleted: { uuid, name: db.name, volumesKept: !deleteVolumes },
},
});
}
// ──────────────────────────────────────────────────
// Phase 4: auth providers (Coolify services, curated allowlist)
// ──────────────────────────────────────────────────
const AUTH_PROVIDERS_MCP: Record<string, string> = {
pocketbase: "pocketbase",
authentik: "authentik",
keycloak: "keycloak",
"keycloak-with-postgres": "keycloak-with-postgres",
"pocket-id": "pocket-id",
"pocket-id-with-postgresql": "pocket-id-with-postgresql",
logto: "logto",
"supertokens-with-postgresql": "supertokens-with-postgresql",
};
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({
result: {
providers: all
.filter((s) => {
for (const slug of slugs) {
if (s.name === slug || s.name.startsWith(`${slug}-`)) return true;
}
return false;
})
.map((s) => ({ uuid: s.uuid, name: s.name, status: s.status ?? null })),
allowedProviders: Object.keys(AUTH_PROVIDERS_MCP),
},
});
}
async function toolAuthCreate(
principal: Principal,
params: Record<string, any>,
) {
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) {
return NextResponse.json(
{
error: `Unsupported provider "${key}"`,
allowed: Object.keys(AUTH_PROVIDERS_MCP),
},
{ status: 400 },
);
}
const name = slugify(String(params.name ?? key));
const { uuid } = await createService({
projectUuid,
type: coolifyType,
name,
description: params.description ? String(params.description) : undefined,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
instantDeploy: params.instantDeploy !== false,
});
const svc = await getServiceInWorkspace(uuid, ownedUuids);
return NextResponse.json({
result: {
uuid: svc.uuid,
name: svc.name,
provider: key,
status: svc.status ?? null,
},
});
}
async function toolAuthDelete(
principal: Principal,
params: Record<string, any>,
) {
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 getServiceInWorkspace(uuid, ownedUuids);
const confirm = String(params.confirm ?? "");
if (confirm !== svc.name) {
return NextResponse.json(
{
error: "Confirmation required",
hint: `Pass confirm=${svc.name} to delete`,
},
{ status: 409 },
);
}
const deleteVolumes = params.deleteVolumes === true;
await deleteService(uuid, {
deleteConfigurations: true,
deleteVolumes,
deleteConnectedNetworks: true,
dockerCleanup: true,
});
await unlinkResource(uuid).catch((e) =>
console.warn("[mcp services.delete] unlink failed", e),
);
return NextResponse.json({
result: {
ok: true,
deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes },
},
});
}
// ──────────────────────────────────────────────────
// Phase 5.1: domains (OpenSRS)
// ──────────────────────────────────────────────────
async function toolDomainsSearch(
principal: Principal,
params: Record<string, any>,
) {
const namesIn = Array.isArray(params.names) ? params.names : [params.name];
const names = namesIn
.filter(
(x: unknown): x is string => typeof x === "string" && x.trim().length > 0,
)
.map((s: string) =>
s
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, ""),
);
if (names.length === 0) {
return NextResponse.json(
{ error: "Params { names: string[] } or { name: string } required" },
{ status: 400 },
);
}
const period =
typeof params.period === "number" && params.period > 0 ? params.period : 1;
const { checkDomain } = await import("@/lib/opensrs");
const results = await Promise.all(
names.map(async (name: string) => {
try {
const r = await checkDomain(name, period);
return {
domain: name,
available: r.available,
price: r.price ?? null,
currency: r.currency ?? process.env.OPENSRS_CURRENCY ?? "CAD",
period: r.period ?? period,
};
} catch (err) {
return {
domain: name,
available: false,
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
return NextResponse.json({
result: { mode: process.env.OPENSRS_MODE ?? "test", results },
});
}
async function toolDomainsList(principal: Principal) {
const { listDomainsForWorkspace } = await import("@/lib/domains");
const rows = await listDomainsForWorkspace(principal.workspace.id);
return NextResponse.json({
result: rows.map((r) => ({
id: r.id,
domain: r.domain,
tld: r.tld,
status: r.status,
registeredAt: r.registered_at,
expiresAt: r.expires_at,
periodYears: r.period_years,
dnsProvider: r.dns_provider,
})),
});
}
async function toolDomainsGet(
principal: Principal,
params: Record<string, any>,
) {
const name = String(params.domain ?? params.name ?? "")
.trim()
.toLowerCase();
if (!name)
return NextResponse.json(
{ error: 'Param "domain" is required' },
{ status: 400 },
);
const { getDomainForWorkspace } = await import("@/lib/domains");
const row = await getDomainForWorkspace(principal.workspace.id, name);
if (!row)
return NextResponse.json(
{ error: "Domain not found in this workspace" },
{ status: 404 },
);
return NextResponse.json({
result: {
id: row.id,
domain: row.domain,
tld: row.tld,
status: row.status,
registrarOrderId: row.registrar_order_id,
periodYears: row.period_years,
registeredAt: row.registered_at,
expiresAt: row.expires_at,
dnsProvider: row.dns_provider,
dnsZoneId: row.dns_zone_id,
dnsNameservers: row.dns_nameservers,
},
});
}
async function toolDomainsRegister(
principal: Principal,
params: Record<string, any>,
) {
const raw = String(params.domain ?? "")
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
return NextResponse.json(
{ error: "`domain` is required and must be a valid hostname" },
{ status: 400 },
);
}
if (!params.contact || typeof params.contact !== "object") {
return NextResponse.json(
{
error:
"`contact` object is required (see /api/workspaces/[slug]/domains POST schema)",
},
{ status: 400 },
);
}
const {
domainTld: tldOf,
minPeriodFor,
registerDomain,
OpenSrsError,
} = await import("@/lib/opensrs");
const tld = tldOf(raw);
if (tld === "ca" && !params.ca) {
return NextResponse.json(
{ error: ".ca requires `ca.cprCategory` and `ca.legalType`" },
{ status: 400 },
);
}
const period = minPeriodFor(
tld,
typeof params.period === "number" ? params.period : 1,
);
const {
createDomainIntent,
getDomainForWorkspace,
markDomainFailed,
markDomainRegistered,
recordDomainEvent,
} = await import("@/lib/domains");
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
if (intent && intent.status === "active") {
return NextResponse.json(
{ error: `Domain ${raw} is already registered`, domainId: intent.id },
{ status: 409 },
);
}
if (!intent) {
intent = await createDomainIntent({
workspaceId: principal.workspace.id,
domain: raw,
createdBy: principal.userId,
periodYears: period,
whoisPrivacy: params.whoisPrivacy ?? true,
});
}
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: "register.attempt",
payload: { period, via: "mcp", mode: process.env.OPENSRS_MODE ?? "test" },
});
try {
const result = await registerDomain({
domain: raw,
period,
contact: params.contact,
nameservers: params.nameservers,
whoisPrivacy: params.whoisPrivacy ?? true,
ca: params.ca,
});
const updated = await markDomainRegistered({
domainId: intent.id,
registrarOrderId: result.orderId,
registrarUsername: result.regUsername,
registrarPassword: result.regPassword,
periodYears: period,
pricePaidCents: null,
priceCurrency: process.env.OPENSRS_CURRENCY ?? "CAD",
registeredAt: new Date(),
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
});
await recordDomainEvent({
domainId: intent.id,
workspaceId: principal.workspace.id,
type: "register.success",
payload: { orderId: result.orderId, period, via: "mcp" },
});
return NextResponse.json({
result: {
ok: true,
mode: process.env.OPENSRS_MODE ?? "test",
domain: {
id: updated.id,
domain: updated.domain,
status: updated.status,
registrarOrderId: updated.registrar_order_id,
expiresAt: updated.expires_at,
},
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await markDomainFailed(intent.id, message);
if (err instanceof OpenSrsError) {
return NextResponse.json(
{
error: "Registration failed",
registrarCode: err.code,
details: err.message,
},
{ status: 502 },
);
}
return NextResponse.json(
{ error: "Registration failed", details: message },
{ status: 500 },
);
}
}
async function toolDomainsAttach(
principal: Principal,
params: Record<string, any>,
) {
const apex = String(params.domain ?? params.name ?? "")
.trim()
.toLowerCase();
if (!apex)
return NextResponse.json(
{ error: 'Param "domain" is required' },
{ status: 400 },
);
const { getDomainForWorkspace } = await import("@/lib/domains");
const row = await getDomainForWorkspace(principal.workspace.id, apex);
if (!row)
return NextResponse.json(
{ error: "Domain not found in this workspace" },
{ status: 404 },
);
const { attachDomain, AttachError } = await import("@/lib/domain-attach");
try {
const result = await attachDomain(principal.workspace, row, {
appUuid: typeof params.appUuid === "string" ? params.appUuid : undefined,
ip: typeof params.ip === "string" ? params.ip : undefined,
cname: typeof params.cname === "string" ? params.cname : undefined,
subdomains: Array.isArray(params.subdomains)
? params.subdomains
: undefined,
updateRegistrarNs: params.updateRegistrarNs !== false,
});
return NextResponse.json({
result: {
ok: true,
domain: {
id: result.domain.id,
domain: result.domain.domain,
dnsProvider: result.domain.dns_provider,
dnsZoneId: result.domain.dns_zone_id,
dnsNameservers: result.domain.dns_nameservers,
},
zone: result.zone,
records: result.records,
registrarNsUpdate: result.registrarNsUpdate,
coolifyUpdate: result.coolifyUpdate,
},
});
} catch (err) {
if (err instanceof AttachError) {
return NextResponse.json(
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
{ status: err.status },
);
}
console.error("[mcp domains.attach] unexpected", err);
return NextResponse.json(
{
error: "Attach failed",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
}
// ──────────────────────────────────────────────────
// Phase 5.3: Object storage (GCS via S3-compatible HMAC)
// ──────────────────────────────────────────────────
/**
* Shape of the S3-compatible credentials we expose to agents.
*
* The HMAC *secret* is never returned here — only the access id and
* the bucket/region/endpoint. Use `storage.inject_env` to push the
* full `{accessId, secret}` pair into a Coolify app's env vars
* server-side, where the secret never leaves our network.
*/
function describeWorkspaceStorage(ws: {
slug: string;
gcs_default_bucket_name: string | null;
gcs_hmac_access_id: string | null;
gcp_service_account_email: string | null;
gcp_provision_status: string | null;
}) {
return {
status: ws.gcp_provision_status ?? "pending",
bucket: ws.gcs_default_bucket_name,
region: VIBN_GCS_LOCATION,
endpoint: "https://storage.googleapis.com",
accessKeyId: ws.gcs_hmac_access_id,
serviceAccountEmail: ws.gcp_service_account_email,
note:
"S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. " +
"The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.",
};
}
async function toolStorageDescribe(principal: Principal) {
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) {
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
}
return NextResponse.json({ result: describeWorkspaceStorage(ws) });
}
async function toolStorageProvision(principal: Principal) {
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
return NextResponse.json({ result });
}
/**
* Inject the workspace's storage credentials into a Coolify app as
* env vars, so the app can reach the bucket with any S3 SDK. The
* HMAC secret is read server-side and written directly to Coolify —
* it never transits through the agent or our API response.
*
* Envs written (all tagged is_shown_once so Coolify hides the secret
* in the UI after first render):
* STORAGE_ENDPOINT = https://storage.googleapis.com
* STORAGE_REGION = northamerica-northeast1
* STORAGE_BUCKET = vibn-ws-{slug}-{rand}
* STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id)
* STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once)
* STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS)
*
* Agents can override the env var prefix via `params.prefix`
* (e.g. "S3_" for apps that expect AWS-style names).
*/
async function toolStorageInjectEnv(
principal: Principal,
params: Record<string, any>,
) {
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 getApplicationInWorkspace(appUuid, ownedUuids);
const prefix = String(params.prefix ?? "STORAGE_");
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {
return NextResponse.json(
{
error:
'Param "prefix" must be uppercase ASCII (letters, digits, underscores)',
},
{ status: 400 },
);
}
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws)
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
if (ws.gcp_provision_status !== "ready" || !ws.gcs_default_bucket_name) {
return NextResponse.json(
{
error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`,
},
{ status: 409 },
);
}
const creds = getWorkspaceGcsHmacCredentials(ws);
if (!creds) {
return NextResponse.json(
{
error:
"Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.",
},
{ status: 409 },
);
}
const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [
{ key: `${prefix}ENDPOINT`, value: "https://storage.googleapis.com" },
{ key: `${prefix}REGION`, value: VIBN_GCS_LOCATION },
{ key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name },
{ key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId },
{ key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true },
{ key: `${prefix}FORCE_PATH_STYLE`, value: "true" },
];
const written: string[] = [];
const failed: Array<{ key: string; error: string }> = [];
for (const e of entries) {
try {
await upsertApplicationEnv(appUuid, {
key: e.key,
value: e.value,
is_shown_once: e.shownOnce ?? false,
});
written.push(e.key);
} catch (err) {
failed.push({
key: e.key,
error: err instanceof Error ? err.message : String(err),
});
}
}
return NextResponse.json({
result: {
uuid: appUuid,
prefix,
written,
failed: failed.length ? failed : undefined,
bucket: ws.gcs_default_bucket_name,
},
});
}
// ──────────────────────────────────────────────────
// Gitea — repos & file CRUD (lets the AI write code, not just deploy it)
// ──────────────────────────────────────────────────
//
// Tenant safety: every operation is scoped to `principal.workspace.gitea_org`.
// A workspace can never read or write into another workspace's repos because
// requireGiteaOrg() rejects any path whose owner isn't the caller's org.
function requireGiteaOrg(principal: Principal): string | NextResponse {
const org = principal.workspace.gitea_org;
if (!org) {
return NextResponse.json(
{ error: "Workspace has no Gitea org yet (provisioning incomplete)" },
{ status: 503 },
);
}
return org;
}
function ensureRepoOwnerInOrg(
ownerParam: unknown,
org: string,
): string | NextResponse {
const owner =
typeof ownerParam === "string" && ownerParam.length > 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<string, any>,
) {
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<string, any>,
) {
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<string, any>,
) {
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<string, any>,
) {
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<string, any>,
) {
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<string, any>,
) {
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<string, any>,
) {
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 } });
}
// ── Path B: dev container + shell + filesystem tools ─────────────────
//
// These tools live "inside" the per-project vibn-dev container. The
// AI uses them to author code with sub-second feedback instead of the
// ~5 min Gitea-commit → Coolify-redeploy loop.
//
// Tenant safety strategy:
// 1. loadProjectForPrincipal() verifies the projectId is in the
// caller's workspace (same SELECT pattern as toolProjectsGet).
// 2. ensureDevContainer() / execInDevContainer() take projectId, NOT
// a raw container UUID. The container UUID is fetched from
// fs_project_dev_containers, which is keyed by projectId. A user
// can never address a foreign container directly.
// 3. The vibn-dev image runs as uid 1000 (`vibn`) by default. Coolify
// network policy isolates dev containers from internal Vibn
// services (vibn-postgres, vibn-frontend) — see /vibn-dev/README.
interface ProjectForPath {
id: string;
data: any;
slug: string;
name: string;
}
async function loadProjectForPrincipal(
principal: Principal,
projectId: string,
): Promise<ProjectForPath | null> {
const rows = await query<{ id: string; data: any; slug: string }>(
`SELECT id, data, slug
FROM fs_projects
WHERE id = $1
AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug],
);
if (rows.length === 0) return null;
const r = rows[0];
const d = r.data || {};
return {
id: r.id,
data: d,
slug: r.slug,
name: d.productName || d.name || d.title || r.slug,
};
}
async function pathBGuard(): Promise<NextResponse | null> {
if (await isPathBDisabled()) {
return NextResponse.json(
{
error:
"Path B (AI dev containers) is currently disabled by an admin. Use the Gitea-based tools instead, or contact support.",
},
{ status: 503 },
);
}
return null;
}
function requireProjectId(params: Record<string, any>): string | NextResponse {
const id = String(params.projectId ?? params.project_id ?? "").trim();
if (!id) {
return NextResponse.json(
{ error: 'Param "projectId" is required' },
{ status: 400 },
);
}
return id;
}
async function resolveProjectOr404(
principal: Principal,
params: Record<string, any>,
): Promise<ProjectForPath | NextResponse> {
const idOrErr = requireProjectId(params);
if (idOrErr instanceof NextResponse) return idOrErr;
const project = await loadProjectForPrincipal(principal, idOrErr);
if (!project) {
return NextResponse.json(
{ error: `Project ${idOrErr} not found in this workspace` },
{ status: 404 },
);
}
return project;
}
// ── devcontainer.* ───────────────────────────────────────────────────
async function toolDevContainerEnsure(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
try {
const r = await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
noStart: Boolean(params.noStart),
});
return NextResponse.json({ result: r });
} catch (err) {
// Quota exceeded → 402 with structured payload so the AI's
// tool-error recovery middleware can spot it (see error-recovery.ts).
if (err instanceof QuotaExceededError) {
return NextResponse.json(
{
error: err.message,
code: err.code,
current: err.current,
limit: err.limit,
},
{ status: 402 },
);
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolDevContainerStatus(
principal: Principal,
params: Record<string, any>,
) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const status = await getDevContainerStatus(project.id);
return NextResponse.json({ result: status });
}
async function toolDevContainerSuspend(
principal: Principal,
params: Record<string, any>,
) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
await suspendDevContainer(project.id);
return NextResponse.json({
result: { ok: true, projectId: project.id, state: "suspended" },
});
}
// ── shell.exec ───────────────────────────────────────────────────────
//
// Universal escape hatch. Runs an arbitrary shell command inside
// /workspace as the `vibn` user (uid 1000). Output is capped at 1 MB
// and the call times out at 60s by default (max 10 min).
async function toolShellExec(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const command = typeof params.command === "string" ? params.command : "";
if (!command.trim()) {
return NextResponse.json(
{ error: 'Param "command" is required' },
{ status: 400 },
);
}
const rawCwd = typeof params.cwd === "string" ? params.cwd : ".";
const cwd = normalizeFsPath(rawCwd, project.slug);
if (cwd instanceof NextResponse) return cwd;
let finalCommand = command;
// Soft command rewrite for obvious no-op bad pathing patterns
if (project.slug) {
finalCommand = finalCommand.replace(
new RegExp(`cd\\s+(?:/workspace/)?${project.slug}\\s*&&\\s*`),
"",
);
}
// Lazy-provision: if there's no dev container yet, create one before
// running the command. The first call is ~10-15s; subsequent calls
// skip this branch entirely.
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
});
try {
const result = await execInDevContainer({
projectId: project.id,
command: finalCommand,
cwd,
timeoutMs: Number.isFinite(Number(params.timeoutMs))
? Number(params.timeoutMs)
: Number.isFinite(Number(params.timeout_ms))
? Number(params.timeout_ms)
: undefined,
maxBytes: Number.isFinite(Number(params.maxBytes))
? Number(params.maxBytes)
: undefined,
env:
params.env && typeof params.env === "object" ? params.env : undefined,
user: typeof params.user === "string" ? params.user : undefined,
});
return NextResponse.json({
result: {
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
truncated: result.truncated,
durationMs: result.durationMs,
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 400 },
);
}
}
// ── fs.* ─────────────────────────────────────────────────────────────
//
// Implemented on top of shell.exec for now. Each fs.* call shells out
// to a coreutil (`cat`, `tee`, `rm`, etc) inside the dev container.
// This keeps the surface area tiny and ensures the AI's view of the
// filesystem matches what its `shell.exec` calls see.
//
// Path validation: we lock fs.* to /workspace by default. Absolute
// paths outside /workspace are rejected (prevents the AI from
// stomping on /etc, /home/vibn/.bashrc, etc by accident — though the
// `vibn` user has sudo, so a determined `shell.exec` can still go
// anywhere; fs.* just removes the obvious footguns).
const FS_ROOT = "/workspace";
function shq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function normalizeFsPath(
input: string,
projectSlug?: string,
): string | NextResponse {
if (!input || typeof input !== "string") {
return NextResponse.json(
{ error: 'Param "path" is required' },
{ status: 400 },
);
}
let p = input.trim();
// Remove wrapper prefixes the model commonly hallucinates.
p = p.replace(/^\/workspace\/?/, "");
p = p.replace(/^\.\//, "");
if (projectSlug && p.startsWith(`${projectSlug}/`)) {
p = p.slice(projectSlug.length + 1);
}
const resolved = path.posix.resolve("/workspace", p);
// When projectSlug is set, REJECT paths outside the project root.
// (We use startWith("/workspace/") to ensure it doesn't match "/workspace-other")
if (projectSlug) {
if (resolved !== "/workspace" && !resolved.startsWith("/workspace/")) {
return NextResponse.json(
{
ok: false,
error: `PATH_OUTSIDE_PROJECT: path "${input}" resolves to "${resolved}" which is outside the active project root at "/workspace".`,
},
{ status: 400 },
);
}
} else {
// Workspace-level fallback (legacy behaviour)
if (resolved !== "/workspace" && !resolved.startsWith("/workspace/")) {
return NextResponse.json(
{
error: `Path "${input}" is outside /workspace; use shell.exec for system paths.`,
},
{ status: 400 },
);
}
}
return resolved;
}
async function runFsCmd(
principal: Principal,
project: ProjectForPath,
command: string,
timeoutMs?: number,
): Promise<{
code: number | null;
stdout: string;
stderr: string;
truncated: boolean;
}> {
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
});
const r = await execInDevContainer({
projectId: project.id,
command,
timeoutMs,
});
return {
code: r.code,
stdout: r.stdout,
stderr: r.stderr,
truncated: r.truncated,
};
}
async function toolRequestVisualQA(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const targetPath = String(params.targetPath ?? "").trim();
if (!targetPath) {
return NextResponse.json(
{ error: 'Param "targetPath" is required' },
{ status: 400 },
);
}
const absPath = normalizeFsPath(targetPath, project.slug);
if (absPath instanceof NextResponse) return absPath;
const r = await runFsCmd(
principal,
project,
`test -f ${shq(absPath)} && cat ${shq(absPath)}`,
10000,
);
if (r.code !== 0) {
return NextResponse.json({
error: `Could not read file ${targetPath}. Ensure the path is correct and the file exists.`,
});
}
const fileContent = r.stdout;
const prompt = `You are a strict, world-class Senior Design QA Engineer.
Your job is to evaluate the provided UI code and catch "AI slop" before the user sees it.
First, look at the file path and code, and silently classify the surface (e.g., Marketing Page, SaaS Dashboard, Editorial Content, Social Asset).
Then, evaluate the code against these 5 adaptive dimensions based on that classification:
1. Philosophy & Intent: Does the layout match the purpose? (Marketing needs persuasion/trust; Tools need density/efficiency; Editorial needs readability).
2. Hierarchy: Is the focal point correct for this surface? (Marketing = CTAs/Value Props; Tools = Primary Actions/Data).
3. Execution: Are padding, alignment, and typography mathematically consistent? MUST use CSS variables/tokens, NO hardcoded colors.
4. Specificity: Does the UI use highly realistic, messy data/copy to prove text wrapping and layout hold up? (Reject Lorem Ipsum, "Metric A", or generic stat-slop).
5. Restraint: Is the signal-to-noise ratio appropriate? (Tools require low cognitive load and quiet colors; Marketing requires decisive, focused flourishes).
## The Code (\`${targetPath}\`)
\`\`\`
${fileContent.slice(0, 15000)}
\`\`\`
If it fails ANY criteria, list the exact issues and how to fix them. DO NOT rewrite the whole file, just give concise actionable critique.
If it is flawless and strictly adheres to these dimensions, reply ONLY with "PASS".`;
try {
const aiResponse = await callVibnChat({
systemPrompt: prompt,
messages: [{ role: "user", content: "Perform the Visual QA critique." }],
temperature: 0.1,
});
return NextResponse.json({
result: {
critique: aiResponse.text || "No feedback generated.",
note: "If this returned issues, you MUST fix them using fs.edit before declaring your turn complete.",
},
});
} catch (err: any) {
return NextResponse.json({
error: `QA Agent failed: ${err.message}`,
});
}
}
async function toolFsRead(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ""), project.slug);
if (path instanceof NextResponse) return path;
const offset = Number.isFinite(Number(params.offset))
? Math.max(0, Number(params.offset))
: 0;
const limit = Number.isFinite(Number(params.limit))
? Math.max(1, Number(params.limit))
: 0;
// `test -f`, then read with optional sed window.
let cmd: string;
if (limit > 0) {
const start = offset + 1;
const end = offset + limit;
cmd = `test -f ${shq(path)} && sed -n ${shq(`${start},${end}p`)} ${shq(path)}`;
} else {
cmd = `test -f ${shq(path)} && cat ${shq(path)}`;
}
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
return NextResponse.json(
{
error: `fs.read failed for ${path}: ${r.stderr.trim() || "not a file or missing"}`,
},
{ status: 404 },
);
}
return NextResponse.json({
result: {
path,
content: r.stdout,
truncated: r.truncated,
offset,
limit: limit || null,
},
});
}
async function toolGetDesignTemplate(params: Record<string, unknown>) {
const templateId = String(params.template_id || "").trim();
if (!templateId) {
return NextResponse.json(
{ error: "Missing required parameter 'template_id'" },
{ status: 400 },
);
}
// Prevent directory traversal
if (templateId.includes("..") || templateId.includes("/")) {
return NextResponse.json({ error: "Invalid template_id" }, { status: 400 });
}
const skillPath = path.join(
process.cwd(),
"lib",
"scaffold",
"open-design",
"design-templates",
templateId,
"SKILL.md",
);
try {
if (!fs.existsSync(skillPath)) {
return NextResponse.json(
{ error: `Template '${templateId}' not found or missing SKILL.md` },
{ status: 404 },
);
}
const content = await fs.promises.readFile(skillPath, "utf-8");
return NextResponse.json({ result: { content } });
} catch (err: any) {
return NextResponse.json(
{ error: `Failed to read template: ${err.message}` },
{ status: 500 },
);
}
}
async function toolAppsTemplatesScaffold(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const templateName = String(params.templateName ?? "").trim();
if (!templateName) {
return NextResponse.json(
{ error: "templateName required" },
{ status: 400 },
);
}
// To simulate copying from our internal repo directly into the container,
// we could clone from Gitea if we had it there. But for simplicity, we'll
// generate a small bash script that sets up the Next.js structure inside the container.
// Since we already have full shell.exec access, we'll just return the shell script to the AI
// to run it, or run it ourselves. It's actually safer to just execute the setup directly.
let filesSetup = "";
if (templateName === "dashboard") {
filesSetup = `
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir false --import-alias "@/*" --use-npm --yes
npm install lucide-react
mkdir -p app
cat << 'EOF' > app/page.tsx
import { BarChart3, Users, DollarSign, Activity } from "lucide-react";
export default function Dashboard() {
return (
<div className="min-h-screen bg-neutral-100 text-neutral-900 font-sans p-8">
<div className="max-w-6xl mx-auto space-y-8">
<header className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Overview</h1>
<p className="text-neutral-500 mt-1">Your business performance at a glance.</p>
</div>
<button className="bg-neutral-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors">
Download Report
</button>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Total Revenue" value="$45,231" icon={<DollarSign size={20} />} trend="+20.1%" />
<StatCard title="Active Users" value="2,314" icon={<Users size={20} />} trend="+12.5%" />
<StatCard title="Sales" value="12,234" icon={<BarChart3 size={20} />} trend="+19.2%" />
<StatCard title="Active Sessions" value="573" icon={<Activity size={20} />} trend="-4.3%" bad />
</div>
</div>
</div>
);
}
function StatCard({ title, value, icon, trend, bad = false }: any) {
return (
<div className="bg-white p-6 rounded-xl border border-neutral-200 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-center text-neutral-500">
<h3 className="text-sm font-medium">{title}</h3>
{icon}
</div>
<div className="mt-4">
<div className="text-2xl font-semibold">{value}</div>
<div className={\`text-xs mt-1 font-medium \${bad ? "text-red-600" : "text-emerald-600"}\`}>
{trend} from last month
</div>
</div>
</div>
);
}
EOF
`;
} else if (templateName === "pitch-deck") {
filesSetup = `
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir false --import-alias "@/*" --use-npm --yes
mkdir -p app
cat << 'EOF' > app/page.tsx
export default function PitchDeck() {
return (
<div className="min-h-screen bg-[#0a0a0b] text-[#f1efea] font-sans selection:bg-[#f1efea] selection:text-[#0a0a0b]">
<main className="max-w-5xl mx-auto px-6 py-24 flex flex-col justify-center min-h-screen space-y-8">
<div className="text-[11px] tracking-[0.15em] uppercase font-medium text-[#f1efea]/60">Confidential Pitch Deck</div>
<h1 className="text-6xl md:text-8xl font-serif tracking-tight leading-[1.05]">
The Future of<br/><span className="italic opacity-80">Software Design</span>
</h1>
<p className="text-xl md:text-2xl max-w-2xl text-[#f1efea]/70 font-light leading-relaxed mt-4">
A new paradigm for building digital products. Faster, more deterministic, and completely open.
</p>
</main>
</div>
);
}
EOF
`;
} else {
return NextResponse.json(
{ error: `Unknown template: ${templateName}` },
{ status: 400 },
);
}
const cmd = `cd /workspace/${project.slug} && ${filesSetup}`;
const r = await runFsCmd(principal, project, cmd, 60000);
return NextResponse.json({
result: {
success: r.code === 0,
stdout: r.stdout,
stderr: r.stderr,
note:
"Template scaffolded successfully into /workspace/" +
project.slug +
". You can now run dev_server_start to preview it.",
},
});
}
async function toolGenerateMedia(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const prompt = String(params.prompt ?? "").trim();
const type = String(params.type ?? "image").trim();
const outputPath = String(params.outputPath ?? "").trim();
if (!prompt || !outputPath) {
return NextResponse.json(
{ error: "prompt and outputPath required" },
{ status: 400 },
);
}
const absPath = normalizeFsPath(outputPath);
if (absPath instanceof NextResponse) return absPath;
// Ideally this would call DALL-E or Seedance/HyperFrames real APIs like open-design.
// For now, we will simulate the file creation so the AI's workflow is intact.
const cmd = `mkdir -p $(dirname ${shq(absPath)}) && echo "Mock ${type} generated for: ${prompt}" > ${shq(absPath)}`;
const r = await runFsCmd(principal, project, cmd, 10000);
return NextResponse.json({
result: {
success: r.code === 0,
path: outputPath,
note: `Media (${type}) saved to ${outputPath}. You can now reference this file in your UI components.`,
},
});
}
async function toolFsWrite(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ""), project.slug);
if (path instanceof NextResponse) return path;
const content = typeof params.content === "string" ? params.content : "";
const force = Boolean(params.force);
const b64 = Buffer.from(content, "utf8").toString("base64");
const py = `import sys, os, difflib, base64
path = sys.argv[1]
new_b64 = sys.argv[2]
force_overwrite = sys.argv[3] == 'true'
new_content = base64.b64decode(new_b64).decode('utf-8')
if os.path.exists(path) and not force_overwrite:
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
old_content = f.read()
old_lines = old_content.splitlines()
new_lines = new_content.splitlines()
if len(old_lines) > 5:
diff = list(difflib.unified_diff(old_lines, new_lines))
add_rem = len([l for l in diff if l.startswith('+') or l.startswith('-')]) - 2
change_pct = add_rem / max(1, len(old_lines))
if change_pct > 0.60:
sys.stderr.write(f"REWRITE_GUARD_TRIGGERED: Your fs_write would overwrite {int(change_pct*100)}% of this {len(old_lines)}-line file. To replace large blocks or the entire file, please use surgical 'fs_edit' anchors instead, or pass 'force: true' on fs_write if you genuinely need a full rewrite.\\n")
sys.exit(4)
except Exception as e:
pass
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write(new_content)
`;
const pyB64 = Buffer.from(py, "utf8").toString("base64");
const cmd = `python3 -c "$(printf %s ${shq(pyB64)} | base64 -d)" ${shq(path)} ${shq(b64)} ${shq(String(force))} && sha256sum ${shq(path)} | cut -d' ' -f1 && wc -c < ${shq(path)}`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
const status = r.code === 4 ? 409 : 500;
return NextResponse.json(
{ error: `fs.write failed: ${r.stderr.trim() || "unknown error"}` },
{ status },
);
}
const stdoutParts = r.stdout.split("\n").filter(Boolean);
const { createHash } = require("crypto");
const bytes = Buffer.byteLength(content, "utf8");
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
// If we are writing to schema.prisma, automatically generate Prisma Client
if (path.endsWith("schema.prisma")) {
const prismaDir = path.replace(/\/prisma\/schema\.prisma$/, "");
console.log(
`[Prisma Hook] Automatically generating prisma client in ${prismaDir}...`,
);
runFsCmd(
principal,
project,
`cd ${shq(prismaDir)} && npx prisma generate`,
).catch(() => {});
}
return NextResponse.json({
result: { ok: true, path, bytes, sha256 },
});
}
async function toolFsEdit(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ""), project.slug);
if (path instanceof NextResponse) return path;
const newString =
typeof params.newString === "string" ? params.newString : "";
const startLine = Number(params.startLine);
const endLine = Number(params.endLine);
const hasLineNumbers = Number.isFinite(startLine) && Number.isFinite(endLine);
const oldString =
typeof params.oldString === "string" ? params.oldString : "";
const replaceAll = Boolean(params.replaceAll);
if (!hasLineNumbers && !oldString) {
return NextResponse.json(
{ error: "Provide either startLine/endLine OR oldString" },
{ status: 400 },
);
}
const payload = {
path,
newString,
hasLineNumbers,
startLine: hasLineNumbers ? startLine : 0,
endLine: hasLineNumbers ? endLine : 0,
oldString,
replaceAll,
};
const py = `import json, sys
spec = json.loads(sys.stdin.read())
try:
with open(spec['path'], 'r', encoding='utf-8') as f:
src = f.read()
except FileNotFoundError:
sys.stderr.write('file not found\\n')
sys.exit(2)
new_str = spec['newString']
old = spec.get('oldString', '')
if old:
n = src.count(old)
if n == 0:
sys.stderr.write("Anchor string (oldString) not found in file. Stale context or incorrect file? Code was NOT edited.\\n")
sys.exit(3)
if n > 1 and not spec.get('replaceAll', False):
sys.stderr.write(f"Anchor string (oldString) found {n}x (non-unique). Please include more lines of surrounding context to uniquely identify the target code block.\\n")
sys.exit(3)
out = src.replace(old, new_str) if spec.get('replaceAll', False) else src.replace(old, new_str, 1)
n_replaced = n if spec.get('replaceAll', False) else 1
else:
if spec.get('hasLineNumbers'):
lines = src.splitlines(keepends=True)
start = max(0, spec['startLine'] - 1)
end = min(len(lines), spec['endLine'])
if start > len(lines):
sys.stderr.write('startLine is past end of file\\n')
sys.exit(2)
new_lines = new_str.splitlines(keepends=True)
if new_lines and not new_lines[-1].endswith('\\n'):
new_lines[-1] += '\\n'
lines[start:end] = new_lines
out = "".join(lines)
n_replaced = 1
else:
sys.stderr.write('Provide either oldString or startLine/endLine\\n')
sys.exit(2)
with open(spec['path'], 'w', encoding='utf-8') as f:
f.write(out)
print(n_replaced)`;
const b64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
const pyB64 = Buffer.from(py, "utf8").toString("base64");
const cmd = `printf %s ${shq(b64)} | base64 -d | python3 -c "$(printf %s ${shq(pyB64)} | base64 -d)" && echo "---" && sha256sum ${shq(path)} | cut -d' ' -f1 && wc -c < ${shq(path)}`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
const status = r.code === 2 ? 404 : r.code === 3 ? 409 : 500;
return NextResponse.json(
{
error: `fs.edit failed: ${r.stderr.trim() || "unknown error"}`,
code: r.code,
},
{ status },
);
}
const stdoutParts = r.stdout.split("---");
const replacementsStr = stdoutParts[0].trim();
const hashAndSize = stdoutParts[1] ? stdoutParts[1].trim().split("\n") : [];
return NextResponse.json({
result: {
ok: true,
path,
replacements: parseInt(replacementsStr || "0", 10),
sha256: hashAndSize[0] ? hashAndSize[0].trim() : undefined,
bytes: hashAndSize[1] ? parseInt(hashAndSize[1].trim(), 10) : undefined,
},
});
}
async function toolFsList(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ""), project.slug);
if (path instanceof NextResponse) return path;
const cmd = `cd ${shq(path)} && ls -lA --time-style=long-iso 2>&1 | head -200`;
const r = await runFsCmd(principal, project, cmd);
return NextResponse.json({
result: { path, listing: r.stdout, code: r.code },
});
}
async function toolFsDelete(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ""), project.slug);
if (path instanceof NextResponse) return path;
const recursive = Boolean(params.recursive);
// Belt-and-suspenders: never let `rm -rf /workspace` itself slip through.
if (path === FS_ROOT) {
return NextResponse.json(
{ error: "Refusing to delete /workspace itself." },
{ status: 400 },
);
}
const cmd = `rm ${recursive ? "-rf" : "-f"} ${shq(path)}`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
return NextResponse.json(
{ error: `fs.delete failed: ${r.stderr.trim()}` },
{ status: 500 },
);
}
return NextResponse.json({ result: { ok: true, path } });
}
async function toolFsGlob(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const pattern = String(params.pattern ?? "").trim();
if (!pattern) {
return NextResponse.json(
{ error: 'Param "pattern" is required' },
{ status: 400 },
);
}
const rawCwd =
params.cwd === undefined || params.cwd === null || params.cwd === ""
? "."
: String(params.cwd);
const cwd = normalizeFsPath(rawCwd, project.slug);
if (cwd instanceof NextResponse) return cwd;
// ripgrep --files --glob is faster + smarter than `find` and respects .gitignore.
const cmd = `cd ${shq(cwd)} && rg --files --glob ${shq(pattern)} | head -500`;
const r = await runFsCmd(principal, project, cmd);
const files = r.stdout
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
return NextResponse.json({
result: { pattern, cwd, files, truncated: files.length === 500 },
});
}
async function toolFsGrep(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const pattern = String(params.pattern ?? "").trim();
if (!pattern) {
return NextResponse.json(
{ error: 'Param "pattern" is required' },
{ status: 400 },
);
}
const rawCwd =
params.cwd === undefined || params.cwd === null || params.cwd === ""
? "."
: String(params.cwd);
const cwd = normalizeFsPath(rawCwd, project.slug);
if (cwd instanceof NextResponse) return cwd;
const glob =
typeof params.glob === "string" && params.glob.trim()
? params.glob.trim()
: null;
const ctx = Number.isFinite(Number(params.contextLines))
? Math.min(10, Math.max(0, Number(params.contextLines)))
: 0;
const flags = [
"--no-heading",
"--line-number",
"--max-count",
"50",
"--max-columns",
"300",
ctx ? `--context ${ctx}` : "",
glob ? `--glob ${shq(glob)}` : "",
]
.filter(Boolean)
.join(" ");
const cmd = `cd ${shq(cwd)} && rg ${flags} ${shq(pattern)} | head -500`;
const r = await runFsCmd(principal, project, cmd);
return NextResponse.json({
result: { pattern, cwd, glob, matches: r.stdout, truncated: r.truncated },
});
}
async function toolDatabasesLogs(
principal: Principal,
params: Record<string, any>,
) {
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 getDatabaseInWorkspace(uuid, ownedUuids);
const linesRaw = Number(params.lines ?? 200);
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
try {
// We add --timestamps (or -t) to get timestamps at the start of each row
const cmd = `cid=$(docker ps -a --filter name=${uuid} --format '{{.Names}}' | head -1); if [ -z "$cid" ]; then echo "NO_CONTAINER"; exit 0; fi; docker logs --timestamps --tail ${lines} "$cid" 2>&1`;
const res = await runOnCoolifyHost(cmd, { timeoutMs: 15_000 });
if (res.code !== 0) {
throw new Error(`docker logs exited ${res.code}: ${res.stderr.trim()}`);
}
const raw =
res.stdout.trim() === "NO_CONTAINER"
? "No running container found for this database."
: res.stdout;
return NextResponse.json({ result: raw });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
async function toolDeploymentsLogs(
principal: Principal,
params: Record<string, any>,
) {
// Validate workspace
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json(
{ error: 'Param "uuid" is required' },
{ status: 400 },
);
try {
const res = await coolifyFetch(`/deployments/${uuid}/logs`);
return NextResponse.json({
result: res.logs || "No logs available for this deployment.",
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// ── dev_server.* ─────────────────────────────────────────────────────
async function toolDevServerStart(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const command = String(params.command ?? "").trim();
if (!command) {
return NextResponse.json(
{
error: `Param "command" (string) is required`,
},
{ status: 400 },
);
}
const port =
Number.isFinite(Number(params.port)) &&
Number(params.port) >= PREVIEW_BASE_PORT &&
Number(params.port) < PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT
? Number(params.port)
: PREVIEW_BASE_PORT;
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
});
try {
const row = await startDevServer({
projectId: project.id,
projectSlug: project.slug,
command,
port,
name: typeof params.name === "string" ? params.name : undefined,
workspace: principal.workspace,
});
// We mark it healthy immediately. Webpack compiles are taking too long
// on cold boots and causing the probe to fail and the AI to retry endlessly.
// The Traefik router will hold the connection open for the user until it responds.
let isHealthy = true;
let failureOutput = "";
// We still fire the probe in the background so it eventually logs if it fails,
// but we don't await it.
probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
console.error("[dev_server.start] Async probe failed later:", err);
});
if (!isHealthy) {
let recentLogs = "";
try {
recentLogs = await tailDevServerLog(project.id, row.id, 50);
} catch (logErr: any) {
recentLogs = `Failed to retrieve logs: ${logErr.message || String(logErr)}`;
}
return NextResponse.json({
result: {
ok: false,
error:
"Server failed to start or bind to port within the timeout window.",
logs: recentLogs,
healthCheck: {
status: 500,
output: failureOutput,
},
},
});
}
return NextResponse.json({
result: {
ok: true,
id: row.id,
name: row.name,
port: row.port,
pid: row.pid,
previewUrl: row.preview_url,
state: row.state,
healthCheck: {
status: 200,
},
note:
"Preview URL is auto-published via Traefik labels baked into the dev-container compose. " +
"It will respond once (a) DNS *.preview.vibnai.com resolves to the Coolify host and " +
"(b) Traefik issues a wildcard cert. Until then, verify the server inside the container with " +
"`shell.exec curl http://localhost:" +
row.port +
"`.",
},
});
} catch (err) {
if (err instanceof PortBusyError) {
return NextResponse.json(
{
error: err.message,
code: "PORT_BUSY",
port: err.port,
listenerPid: err.listenerPid,
},
{ status: 409 },
);
}
if (err instanceof PortOutOfRangeError) {
return NextResponse.json(
{
error: err.message,
code: "PORT_OUT_OF_RANGE",
allowedRange: [
PREVIEW_BASE_PORT,
PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1,
],
},
{ status: 400 },
);
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolDevServerStop(
principal: Principal,
params: Record<string, any>,
) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const id = String(params.id ?? "").trim();
if (!id)
return NextResponse.json(
{ error: 'Param "id" is required' },
{ status: 400 },
);
try {
await stopDevServer(project.id, id);
return NextResponse.json({ result: { ok: true, id, state: "stopped" } });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolDevServerList(
principal: Principal,
params: Record<string, any>,
) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const rows = await listDevServers(project.id);
return NextResponse.json({
result: rows.map((r) => ({
id: r.id,
name: r.name,
command: r.command,
port: r.port,
pid: r.pid,
previewUrl: r.preview_url,
state: r.state,
startedAt: r.started_at,
})),
});
}
async function toolDevServerLogs(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const id = String(params.id ?? "").trim();
if (!id)
return NextResponse.json(
{ error: 'Param "id" is required' },
{ status: 400 },
);
const lines = Number.isFinite(Number(params.lines))
? Number(params.lines)
: 200;
const log = await tailDevServerLog(project.id, id, lines);
return NextResponse.json({ result: { id, log } });
}
// ── ship ─────────────────────────────────────────────────────────────
//
// "Graduate to production." Pushes /workspace to the project's main
// Gitea branch and triggers a Coolify production deployment if the
// project is wired to one (apps_create-style).
async function toolShip(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const message =
typeof params.commitMsg === "string" && params.commitMsg.trim()
? params.commitMsg.trim()
: `ship: ${new Date().toISOString()}`;
const repo =
(typeof params.repo === "string" && params.repo.trim()) || project.slug;
const branch =
typeof params.branch === "string" && params.branch.trim()
? params.branch.trim()
: "main";
// Pre-req: dev container exists. (No silent ensure here — `ship` is a
// significant action; if there's no container there's nothing to ship.)
const status = await getDevContainerStatus(project.id);
if (!status.exists) {
return NextResponse.json(
{
error:
"No dev container for this project — nothing to ship. Use shell.exec to scaffold first.",
},
{ status: 400 },
);
}
// git add/commit/push. We init+remote-add if the repo has no .git
// yet, using the workspace bot's PAT.
const creds = getWorkspaceBotCredentials(principal.workspace);
if (!creds) {
return NextResponse.json(
{ error: "Workspace has no Gitea bot yet; cannot push." },
{ status: 503 },
);
}
const apiHost = new URL(GITEA_API_URL).host;
const remote = `https://${creds.username}:${creds.token}@${apiHost}/${creds.org}/${repo}.git`;
// Capture the resulting HEAD SHA on stdout in a parseable form so we
// can return it to the caller without a second exec round-trip.
const cmd = `set -e
cd /workspace/${project.slug}
if [ ! -d .git ]; then
git init -q
git checkout -b ${shq(branch)}
fi
git config user.email vibn-bot@vibnai.com
git config user.name 'Vibn Bot'
git remote remove origin 2>/dev/null || true
git remote add origin ${shq(remote)}
git add -A
if git diff --cached --quiet HEAD 2>/dev/null; then
echo '(no changes to commit)'
else
git commit -q -m ${shq(message)}
fi
git push -u origin HEAD:${shq(branch)} 2>&1 | tail -5
echo "VIBN_SHIP_SHA=$(git rev-parse HEAD)"`;
let pushOutput = "";
let commitSha: string | null = null;
try {
const r = await execInDevContainer({
projectId: project.id,
command: cmd,
timeoutMs: 60_000,
});
const combined = (r.stdout + r.stderr).trim();
const shaMatch = combined.match(/VIBN_SHIP_SHA=([0-9a-f]{7,40})/);
commitSha = shaMatch ? shaMatch[1] : null;
pushOutput = combined.replace(/VIBN_SHIP_SHA=[0-9a-f]+\s*$/, "").trim();
if (r.code !== 0) {
return NextResponse.json(
{ error: `git push failed: ${pushOutput}` },
{ status: 500 },
);
}
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
// Build verification URLs the AI can hand to the user without
// additional tool calls (the previous behaviour cost ~7 follow-up
// calls per ship: gitea.repos.list, gitea.credentials, multiple
// shell.exec verifications, apps_list).
const giteaWebHost = new URL(GITEA_API_URL).host.replace(/^api\./, "");
const giteaCommitUrl = commitSha
? `https://${giteaWebHost}/${creds.org}/${repo}/commit/${commitSha}`
: null;
const giteaCompareUrl = `https://${giteaWebHost}/${creds.org}/${repo}/commits/branch/${branch}`;
// Trigger Coolify deploy if the project is linked to one.
let deploymentUuid: string | null = null;
let coolifyDeployUrl: string | null = null;
const linkedAppUuid =
typeof project.data?.coolifyAppUuid === "string" &&
project.data.coolifyAppUuid.trim()
? project.data.coolifyAppUuid.trim()
: null;
const coolifyHost = process.env.COOLIFY_BASE_URL
? new URL(process.env.COOLIFY_BASE_URL).host
: null;
if (linkedAppUuid && Boolean(params.deploy ?? true)) {
try {
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getApplicationInWorkspace(linkedAppUuid, ownedUuids);
const dep = await deployApplication(linkedAppUuid, { force: false });
deploymentUuid = dep.deployment_uuid;
if (coolifyHost && deploymentUuid) {
coolifyDeployUrl = `https://${coolifyHost}/project/${linkedAppUuid}/deployments/${deploymentUuid}`;
}
} catch (err) {
return NextResponse.json({
result: {
repo,
branch,
message,
pushed: true,
commitSha,
pushOutput,
giteaCommitUrl,
deploymentTriggered: false,
deployError: err instanceof Error ? err.message : String(err),
},
});
}
}
return NextResponse.json({
result: {
repo,
branch,
message,
pushed: true,
commitSha,
pushOutput,
giteaCommitUrl,
giteaBranchUrl: giteaCompareUrl,
deploymentTriggered: Boolean(deploymentUuid),
deploymentUuid,
coolifyDeployUrl,
coolifyAppUuid: linkedAppUuid,
// Tell the AI exactly what to say in the next text turn so it
// doesn't waste tool rounds verifying.
summaryHint: deploymentUuid
? `Pushed commit ${commitSha?.slice(0, 7) ?? "?"} to ${repo}@${branch} and triggered Coolify deployment ${deploymentUuid}. Show the user commitSha (full or short) and coolifyDeployUrl. Do NOT call additional tools to verify.`
: linkedAppUuid
? `Pushed commit ${commitSha?.slice(0, 7) ?? "?"}, deploy was skipped per deploy=false. Show commitSha and giteaCommitUrl.`
: `Pushed commit ${commitSha?.slice(0, 7) ?? "?"}. No Coolify app linked yet — tell the user to call apps_create once before the next ship. Show commitSha and giteaCommitUrl.`,
},
});
}
// ── Plan (vision · ideas · tasks · decisions) ────────────────────────────────
//
// Mirrors the same storage shape as /api/projects/[projectId]/plan but is
// callable by the AI through the MCP bridge. Every call validates the
// project belongs to the calling principal's workspace before mutating.
interface PlanIdea {
id: string;
text: string;
createdAt: string;
}
// Tasks are markdown-bodied scoped units of work. `text` is the legacy
// field from the v1 single-line shape; `title` + `description` is the
// current shape. Reader migrates legacy rows on the fly.
type PlanTaskStatus = "open" | "in_progress" | "review" | "done" | "blocked";
interface PlanTask {
id: string;
title: string;
description?: string;
status: PlanTaskStatus;
createdAt: string;
startedAt?: string;
doneAt?: string;
text?: string; // legacy
agent?: {
runId: string;
startedAt: string;
finishedAt?: string;
status: "queued" | "running" | "succeeded" | "failed";
} | null;
}
interface PlanDecision {
id: string;
title: string;
choice: string;
why?: string;
createdAt: string;
}
export interface BlueprintDocs {
stories?: string;
acceptance?: string;
success?: string;
ui_design?: string;
tech_context?: string;
data_model?: string;
file_structure?: string;
tasks?: string;
checklist?: string;
}
interface PlanShape {
vision?: string;
blueprint?: BlueprintDocs;
ideas: PlanIdea[];
tasks: PlanTask[];
decisions: PlanDecision[];
}
function planNewId(): string {
return Math.random().toString(36).slice(2, 11);
}
async function loadPlanProject(principal: Principal, projectId: string) {
const rows = await query<{ id: string; data: any }>(
`SELECT id, data FROM fs_projects
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug],
);
return rows[0] ?? null;
}
function readPlanFromData(data: any): PlanShape {
const raw = (data?.plan ?? {}) as Partial<PlanShape>;
// Mirror the legacy-task migration in app/api/projects/[projectId]/plan/route.ts:
// tasks created in v1 only have `text`; coerce them on the fly so the AI
// sees a consistent shape regardless of when a row was written.
const tasksIn = Array.isArray(raw.tasks)
? (raw.tasks as Array<Partial<PlanTask>>)
: [];
const tasks: PlanTask[] = tasksIn.map((t) => ({
id: String(t.id ?? planNewId()),
title: String(t.title ?? t.text ?? "").trim(),
description: typeof t.description === "string" ? t.description : "",
status: (t.status === "in_progress" ||
t.status === "done" ||
t.status === "blocked"
? t.status
: "open") as PlanTaskStatus,
agent: t.agent ?? null,
createdAt: String(t.createdAt ?? new Date().toISOString()),
startedAt: t.startedAt,
doneAt: t.doneAt,
text: t.text,
}));
return {
vision: data?.productVision ?? raw.vision,
blueprint: raw.blueprint ?? {},
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks,
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
};
}
async function writePlanForProject(
projectId: string,
plan: PlanShape,
alsoVision?: string,
) {
if (alsoVision !== undefined) {
await query(
`UPDATE fs_projects
SET data = jsonb_set(
jsonb_set(data, '{plan}', $2::jsonb, true),
'{productVision}', to_jsonb($3::text), true
)
WHERE id = $1`,
[projectId, JSON.stringify({ ...plan, vision: undefined }), alsoVision],
);
} else {
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{plan}', $2::jsonb, true)
WHERE id = $1`,
[projectId, JSON.stringify({ ...plan, vision: undefined })],
);
}
// Immediately notify the SSE connections that this project has changed!
try {
// Note: Postgres NOTIFY does not support parameterized queries ($1) for the payload.
// It requires the payload string to be directly injected or sent as a literal.
// Since projectId is a safe, known UUID/slug generated by us, we can safely template it.
await query(`NOTIFY project_updates, '${projectId}'`);
} catch (e) {
console.error("Failed to notify project updates:", e);
}
}
// Mapping legacy docId -> new specification files
const SPEC_MAPPING: Record<string, string> = {
stories: "01-master-prd.md",
acceptance: "01-master-prd.md",
success: "01-master-prd.md",
ui_design: "08-ui-requirements.md",
tech_context: "03-api-and-integrations.md",
data_model: "05-data-model.md",
file_structure: "09-open-source-references.md",
tasks: "01-master-prd.md",
checklist: "01-master-prd.md",
};
const PLAN_TEMPLATE = `# Implementation Plan: [FEATURE]
**Branch**: \`[###-feature-name]\` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from \`/specs/[###-feature-name]/spec.md\`
## 1. Summary
*Briefly describe the primary requirement and technical approach.*
## 2. Technical Context
- **Language/Version**: [e.g., Node.js v20, Python 3.11]
- **Primary Dependencies**: [e.g., Next.js, Prisma, TailwindCSS]
- **Storage**: [e.g., PostgreSQL, Redis]
- **Testing**: [e.g., Jest, Vitest, Playwright]
## 3. Project Structure Layout
\`\`\`text
specs/[###-feature]/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
└── tasks.md # Phase 2 output
\`\`\`
## 4. Complexity & Constraints
- [e.g. Performance goals, scalability, memory limit]
`;
const TASKS_TEMPLATE = `# Tasks Backlog: [FEATURE NAME]
**Prerequisites**: plan.md (required), spec.md (required)
## 1. Format Guideline: \`[ID] [P?] [Story] Description\`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
- Include exact file paths in task titles
## 2. Phase 1: Setup & Foundations (Prerequisites)
- [ ] T001 Initialize database schemas and Prisma migrations
- [ ] T002 Setup API routes and express middleware structures
## 3. Phase 2: User Story 1 - Core Implementation (Priority: P1)
- [ ] T003 [P] [US1] Create [Model] in src/models/[file].ts
- [ ] T004 [US1] Build /api/v1/resource endpoint in src/routes/[file].ts
## 4. Phase 3: Polish & Verification
- [ ] T005 [P] Run linter and formatting checks
- [ ] T006 Validate end-to-end user journeys
`;
async function readPrdContent(
principal: Principal,
projectId: string,
): Promise<string> {
try {
const res = await toolFsRead(principal, {
projectId,
path: ".vibncode/specs/01-master-prd.md",
});
const data = await res.json();
return data.result?.content || "";
} catch {
return "";
}
}
async function readTaskTemplate(
principal: Principal,
projectId: string,
filename: string,
): Promise<string> {
try {
const res = await toolFsRead(principal, {
projectId,
path: `.vibncode/tasks/${filename}`,
});
const data = await res.json();
return data.result?.content || "";
} catch {
return "";
}
}
async function toolPlanGet(principal: Principal, params: Record<string, any>) {
const projectId = String(params.projectId ?? "").trim();
if (!projectId)
return NextResponse.json({ error: "projectId required" }, { status: 400 });
const [content, planTemplate, tasksTemplate] = await Promise.all([
readPrdContent(principal, projectId),
readTaskTemplate(principal, projectId, "plan-template.md"),
readTaskTemplate(principal, projectId, "tasks-template.md"),
]);
const lines = content.split("\n");
const tasks: any[] = [];
const checklistRegex = /^\s*-\s*\[([ xX])\]\s+(.+)$/;
lines.forEach((line) => {
const match = line.match(checklistRegex);
if (match) {
const taskText = match[2].trim();
tasks.push({
id: taskText,
title: taskText,
status: match[1].toLowerCase() === "x" ? "done" : "open",
});
}
});
return NextResponse.json({
result: {
tasks,
decisions: [],
ideas: [],
templates: {
plan: planTemplate || PLAN_TEMPLATE,
tasks: tasksTemplate || TASKS_TEMPLATE,
},
},
});
}
async function toolPlanVisionSet(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const text = String(params.text ?? "").trim();
if (!projectId || !text)
return NextResponse.json(
{ error: "projectId and text required" },
{ status: 400 },
);
let content = await readPrdContent(principal, projectId);
if (!content) {
content = `# Executive Master Product Requirements Document\n\n## 2. Product Vision\n`;
}
// Prepend or replace under Product Vision section
const visionHeaderRegex = /(## 2\. Product Vision\n)([^#]*)/;
let updatedContent = "";
if (content.match(visionHeaderRegex)) {
updatedContent = content.replace(
visionHeaderRegex,
`$1- **Vision Statement:** ${text}\n\n`,
);
} else {
updatedContent =
content + `\n\n## 2. Product Vision\n- **Vision Statement:** ${text}\n`;
}
await toolFsWrite(principal, {
projectId,
path: ".vibncode/specs/01-master-prd.md",
content: updatedContent,
});
return NextResponse.json({
result: { ok: true },
});
}
async function toolPlanIdeaAdd(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const text = String(params.text ?? "").trim();
if (!projectId || !text)
return NextResponse.json(
{ error: "projectId and text required" },
{ status: 400 },
);
let content = await readPrdContent(principal, projectId);
if (!content) {
content = `# Executive Master Product Requirements Document\n`;
}
const updatedContent = content + `\n\n## Parked Ideas\n- ${text}\n`;
await toolFsWrite(principal, {
projectId,
path: ".vibncode/specs/01-master-prd.md",
content: updatedContent,
});
return NextResponse.json({
result: { ok: true },
});
}
async function toolPlanTaskAdd(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const title = String(params.title ?? params.text ?? "").trim();
const description =
typeof params.description === "string" ? params.description.trim() : "";
if (!projectId || !title) {
return NextResponse.json(
{ error: "projectId and title required" },
{ status: 400 },
);
}
let content = await readPrdContent(principal, projectId);
if (!content) {
content = `# Executive Master Product Requirements Document\n\n## 4. Development Checklist Backlog\n`;
}
let taskBlock = `- [ ] ${title}`;
if (description) {
const indentedDesc = description
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
taskBlock += `\n${indentedDesc}`;
}
const checklistHeader = "## 4. Development Checklist Backlog";
let updatedContent = "";
if (content.includes(checklistHeader)) {
updatedContent = content.replace(
checklistHeader,
`${checklistHeader}\n${taskBlock}`,
);
} else {
updatedContent =
content + `\n\n## 4. Development Checklist Backlog\n${taskBlock}\n`;
}
await toolFsWrite(principal, {
projectId,
path: ".vibncode/specs/01-master-prd.md",
content: updatedContent,
});
return NextResponse.json({
result: {
ok: true,
task: { id: title, title, status: "open" },
},
});
}
async function toolPlanTaskEdit(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const taskId = String(params.taskId ?? params.id ?? "").trim();
const status = String(params.status ?? "").trim();
if (!projectId || !taskId)
return NextResponse.json(
{ error: "projectId and taskId required" },
{ status: 400 },
);
const content = await readPrdContent(principal, projectId);
if (!content) {
return NextResponse.json({ error: "Spec file not found" }, { status: 404 });
}
const lines = content.split("\n");
let found = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(/^(\s*)-\s*\[([ xX])\]\s+(.+)$/);
if (match && match[3].trim() === taskId.trim()) {
const indent = match[1] || "";
const mark = status === "done" || status === "review" ? "x" : " ";
lines[i] = `${indent}- [${mark}] ${match[3]}`;
found = true;
break;
}
}
if (!found) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
await toolFsWrite(principal, {
projectId,
path: ".vibncode/specs/01-master-prd.md",
content: lines.join("\n"),
});
return NextResponse.json({
result: { ok: true },
});
}
async function toolPlanTaskComplete(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const taskId = String(params.taskId ?? params.id ?? "").trim();
return toolPlanTaskEdit(principal, { projectId, taskId, status: "done" });
}
async function toolPlanDocumentUpdate(
principal: Principal,
params: Record<string, any>,
) {
const projectId = String(params.projectId ?? "").trim();
const rawDocId = String(params.docId ?? "").trim();
const blueprintKey = rawDocId.replace(/^prd_/, "");
const content = String(params.content ?? "").trim();
if (!projectId || !blueprintKey || !content) {
return NextResponse.json(
{ error: "projectId, docId, and content required" },
{ status: 400 },
);
}
const filename = SPEC_MAPPING[blueprintKey] || "01-master-prd.md";
await toolFsWrite(principal, {
projectId,
path: `.vibncode/specs/${filename}`,
content,
});
return NextResponse.json({
result: { ok: true },
});
}
// ── Idempotency + unstick helpers ─────────────────────────────────────────────
/**
* Find an existing service in the given Coolify project that was created
* from the same template. Used to short-circuit duplicate apps_create
* calls when the AI doesn't realize Twenty CRM is already deployed.
*/
async function findExistingTemplateService(
coolifyProjectUuid: string,
templateSlug: string,
): Promise<{ uuid: string; name: string } | null> {
try {
const services = await listServicesInProject(coolifyProjectUuid);
for (const s of services) {
// Coolify stores the template slug as `service_type` on the row.
// Some older services may have it under `type`. Match either.
const type =
(s as any).service_type ||
(s as any).type ||
(typeof (s as any).docker_compose_raw === "string" &&
(s as any).docker_compose_raw.includes(templateSlug)
? templateSlug
: null);
if (type === templateSlug) {
return { uuid: s.uuid, name: s.name };
}
}
} catch (e) {
console.warn("[findExistingTemplateService] failed", e);
}
return null;
}
/**
* Find an existing app/service/database in the given Coolify project by
* name (case-insensitive). Used to short-circuit duplicate apps_create
* and databases_create calls regardless of pathway (template / image /
* compose / repo). Returns the first match across all resource types.
*/
async function findExistingResourceByName(
coolifyProjectUuid: string,
name: string,
): Promise<{
uuid: string;
name: string;
type: "application" | "service" | "database";
} | null> {
const target = name.trim().toLowerCase();
if (!target) return null;
try {
const apps = await listApplicationsInProject(coolifyProjectUuid);
for (const a of apps) {
if ((a.name ?? "").toLowerCase() === target) {
return { uuid: a.uuid, name: a.name, type: "application" };
}
}
} catch (e) {
console.warn("[findExistingResourceByName] apps lookup failed", e);
}
try {
const services = await listServicesInProject(coolifyProjectUuid);
for (const s of services) {
if ((s.name ?? "").toLowerCase() === target) {
return { uuid: s.uuid, name: s.name, type: "service" };
}
}
} catch (e) {
console.warn("[findExistingResourceByName] services lookup failed", e);
}
try {
const dbs = await listDatabasesInProject(coolifyProjectUuid);
for (const d of dbs) {
if ((d.name ?? "").toLowerCase() === target) {
return { uuid: d.uuid, name: d.name, type: "database" };
}
}
} catch (e) {
console.warn("[findExistingResourceByName] databases lookup failed", e);
}
return null;
}
function dedupHint(
existing: { uuid: string; name: string; type: string },
pathway: string,
): string {
return (
`A ${existing.type} named "${existing.name}" (uuid ${existing.uuid}) already exists in this project ` +
`(detected via ${pathway} path). Returning it instead of creating a duplicate. ` +
`If the existing one is broken, call apps_unstick { uuid } and apps_deploy { uuid } — DO NOT delete-and-recreate. ` +
`If you genuinely need a SECOND independent instance, re-call with { force: true } and a different { name: }.`
);
}
/**
* apps.unstick — recover a service stuck on a "container name already
* in use" Docker conflict. Force-removes the orphan containers (and
* optionally their volumes), then returns. Caller should then re-call
* apps.deploy to bring the stack back up.
*
* This is the RIGHT recovery path. The WRONG one (and what the AI was
* doing before the system-prompt update) is to delete the service and
* recreate a new one with a fresh UUID — which side-steps the conflict
* by creating new container names but leaves the orphan running and
* forks a duplicate copy of the stack.
*/
async function toolAppsUnstick(
principal: Principal,
params: Record<string, any>,
) {
const uuid = String(params.uuid ?? "").trim();
if (!uuid)
return NextResponse.json({ error: "uuid required" }, { status: 400 });
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
"Coolify SSH is not configured on this deploy. Cannot reach the host to clean orphan containers.",
},
{ status: 503 },
);
}
// Resolve the resource to confirm tenancy + grab its name.
let resourceName = "";
let kind: "application" | "service" | "database" = "application";
try {
const app = await getApplicationInWorkspace(uuid, principal.workspace);
if (app) {
resourceName = app.name;
kind = "application";
}
} catch {}
if (!resourceName) {
try {
const svc = await getServiceInWorkspace(uuid, principal.workspace);
if (svc) {
resourceName = svc.name;
kind = "service";
}
} catch {}
}
if (!resourceName) {
try {
const db = await getDatabaseInWorkspace(uuid, principal.workspace);
if (db) {
resourceName = db.name;
kind = "database";
}
} catch {}
}
if (!resourceName) {
return NextResponse.json(
{ error: "Resource not found in this workspace" },
{ status: 404 },
);
}
const wipeVolumes =
params.wipeVolumes === true || params.wipeVolumes === "true";
// All Coolify-managed containers for a resource carry its UUID as a
// suffix on the container name (e.g. postgres-<uuid>, twenty-<uuid>,
// worker-<uuid>). One docker rm -f against any name ending in -<uuid>
// catches every container in the stack.
const filter = `name=-${uuid}$`;
const cmd = wipeVolumes
? `docker ps -a --filter '${filter}' -q | xargs -r docker rm -f -v`
: `docker ps -a --filter '${filter}' -q | xargs -r docker rm -f`;
let removed: string[] = [];
let stderr = "";
try {
const result = await runOnCoolifyHost(
`docker ps -a --filter '${filter}' --format '{{.Names}}' | tee /tmp/unstick-${uuid}.txt; ` +
cmd,
);
removed = (result.stdout || "")
.split("\n")
.filter(Boolean)
.filter((l) => l.includes(`-${uuid}`));
stderr = result.stderr || "";
} catch (e) {
return NextResponse.json(
{
error: `Failed to clean orphan containers: ${e instanceof Error ? e.message : String(e)}`,
},
{ status: 500 },
);
}
return NextResponse.json({
result: {
uuid,
name: resourceName,
kind,
removedContainers: removed,
wipeVolumes,
stderr: stderr || undefined,
summaryHint:
removed.length === 0
? `No orphan containers found for ${resourceName} (uuid ${uuid}). The conflict may be elsewhere — check apps_logs.`
: `Cleaned ${removed.length} orphan container(s) for ${resourceName}: ${removed.join(", ")}. Now call apps_deploy { uuid: "${uuid}" } to bring the stack back up. Do NOT delete the service.`,
},
});
}
async function toolFsTree(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const rawPath =
params.path === undefined || params.path === null || params.path === ""
? "."
: String(params.path);
const path = normalizeFsPath(rawPath, project.slug);
if (path instanceof NextResponse) return path;
// Use find to generate a tree structure, ignoring node_modules and .git
const cmd = `cd ${shq(path)} && find . -maxdepth 3 -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" | sort | sed -e "s/[^-][^\\/]*\\// |/g" -e "s/|\\([^ ]\\)/|-\\1/" | head -n 300`;
const r = await runFsCmd(principal, project, cmd);
return NextResponse.json({ result: { path, tree: r.stdout, code: r.code } });
}
async function toolMarketResearchRun(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const categories = Array.isArray(params.categories) ? params.categories : [];
const location = String(params.location ?? "").trim();
const explicitlyApproved = Boolean(params.user_explicitly_approved);
if (categories.length === 0 || !location) {
return NextResponse.json(
{ error: "category and location required" },
{ status: 400 },
);
}
if (!explicitlyApproved) {
return NextResponse.json(
{
error:
"This tool costs money to run. You MUST ask the user for explicit permission before calling it. Pass user_explicitly_approved: true once you have permission.",
},
{ status: 403 },
);
}
let bqOptions: any = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
const credentials = JSON.parse(saStr);
bqOptions.credentials = credentials;
bqOptions.projectId = credentials.project_id;
} catch (e) {
console.error(e);
}
}
const bigquery = new BigQuery(bqOptions);
const datasetId = "vibn_market_data";
const tableId = "market_leads";
try {
// 1. Geocode the location string to get Lat/Lng for a geospatial cache check
// In a production scenario, you'd use Google Maps Geocoding API here.
// For now, we will assume the AI passed coordinates like "48.4284,-123.3656" OR we fall back to a text search.
const isCoords = /^-?\d+\.\d+,-?\d+\.\d+/.test(location);
let cacheHit = false;
let existingRows = [];
if (isCoords) {
const [lat, lng] = location.split(",").map((n) => parseFloat(n));
// GEOSPATIAL CACHE CHECK: Do we have leads within 20km of this point?
const queryStr = `
SELECT * FROM \`${bqOptions.projectId}.${datasetId}.${tableId}\`
WHERE category IN UNNEST(@categories)
AND ST_DWithin(geog, ST_GeogPoint(@lng, @lat), 20000) -- 20km radius
LIMIT 100`;
const options = { query: queryStr, params: { categories, lat, lng } };
[existingRows] = await bigquery.query(options);
} else {
// TEXT CACHE CHECK (Fallback if AI passes "Victoria, BC" instead of coords)
const queryStr = `
SELECT * FROM \`${bqOptions.projectId}.${datasetId}.${tableId}\`
WHERE category IN UNNEST(@categories) AND (city = @location OR region = @location OR address LIKE CONCAT('%', @location, '%'))
LIMIT 100`;
[existingRows] = await bigquery.query({
query: queryStr,
params: { categories, location },
});
}
if (existingRows && existingRows.length >= 10) {
// CACHE HIT! We have enough data locally. Don't pay DataForSEO.
const allDomains = existingRows.map((i) => i.website).filter(Boolean);
return NextResponse.json({
result: {
message:
"Cache hit! Retrieved leads from Vibn Data Co-op without querying DataForSEO.",
tamCount: existingRows.length + "+",
leadsEnriched: existingRows.length,
sampleCompetitorDomains: allDomains.slice(0, 10),
isCached: true,
},
});
}
// 2. CACHE MISS: Fetch from DataForSEO
const DFS_LOGIN = "mark@getacquired.com";
const DFS_PASSWORD = "c9893141f2ee1d50";
const DFS_AUTH =
"Basic " + Buffer.from(DFS_LOGIN + ":" + DFS_PASSWORD).toString("base64");
const url =
"https://api.dataforseo.com/v3/business_data/business_listings/search/live";
const payload = [
{
categories: categories.slice(0, 10),
language_name: "English",
is_claimed: true, // Ensure the business is verified
filters: [
["work_time.work_hours.current_status", "<>", "closed_forever"],
],
limit: 100,
},
];
// Format payload cleanly for DataForSEO
if (isCoords) {
payload[0]["location_coordinate"] = `${location},20`; // Append 20km radius
} else {
payload[0]["location_name"] = location;
}
const response = await fetch(url, {
method: "POST",
headers: { Authorization: DFS_AUTH, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (data.status_code !== 20000) {
return NextResponse.json(
{
error: `DataForSEO Error: ${data.tasks?.[0]?.status_message || data.status_message}`,
},
{ status: 500 },
);
}
const taskResult = data.tasks[0].result[0];
const items = taskResult.items || [];
// 3. Process items into the new Normalized Geospatial Schema
const cleanItems = items
.filter((i: any) => i.place_id)
.map((item: any) => {
const emails = (item.contact_info || [])
.filter((c: any) => c.type === "email" || c.type === "mail")
.map((c: any) => c.value);
const lat = item.latitude;
const lng = item.longitude;
let geog = null;
if (lat && lng) {
geog = bigquery.geography(`POINT(${lng} ${lat})`); // PostGIS format is POINT(Longitude Latitude)
}
return {
place_id: item.place_id,
category: item.category || categories[0],
name: item.title || "",
address: item.address_info?.address || item.address || "",
city: item.address_info?.city || null,
region: item.address_info?.region || null,
zip: item.address_info?.zip || null,
country_code: item.address_info?.country_code || null,
latitude: lat || null,
longitude: lng || null,
geog: geog,
phone: item.phone || "",
website: item.domain || item.url || "",
emails: JSON.stringify(emails),
rating: item.rating?.value || null,
reviews_count: item.rating?.votes_count || null,
// --- NEW ENRICHED DATA ---
description: item.description || null,
attributes: item.attributes ? JSON.stringify(item.attributes) : null,
main_image: item.main_image || null,
logo: item.logo || null,
total_photos: item.total_photos || null,
price_level: item.price_level || null,
is_claimed: item.is_claimed || false,
additional_categories: item.additional_categories
? JSON.stringify(item.additional_categories)
: null,
people_also_search: item.people_also_search
? JSON.stringify(item.people_also_search)
: null,
work_hours: item.work_time ? JSON.stringify(item.work_time) : null,
project_id: project.id,
workspace: principal.workspace,
created_at: bigquery.datetime(new Date().toISOString()),
};
});
// 4. INSERT into BigQuery using the robust schema
if (cleanItems.length > 0) {
// We use a stream insert, but BigQuery will reject duplicates based on our place_id (if we set it as primary, but BQ streaming inserts don't strictly enforce PK on the fly. We'll handle upserts in batch jobs later).
await bigquery.dataset(datasetId).table(tableId).insert(cleanItems);
}
const allDomains = cleanItems.map((i) => i.website).filter(Boolean);
return NextResponse.json({
result: {
message:
"Successfully queried market and stored geospatial leads in BigQuery Data Co-op.",
tamCount: taskResult.total_count,
leadsEnriched: cleanItems.length,
sampleCompetitorDomains: allDomains.slice(0, 10),
isCached: false,
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolMarketSeoAnalyze(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const domain = String(params.domain ?? "")
.trim()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0];
if (!domain) {
return NextResponse.json({ error: "domain required" }, { status: 400 });
}
let bqOptions: any = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
bqOptions.credentials = JSON.parse(saStr);
bqOptions.projectId = bqOptions.credentials.project_id;
} catch (e) {}
}
const bigquery = new BigQuery(bqOptions);
// 1. Check BigQuery Cache
try {
const query = `SELECT * FROM \`${bqOptions.projectId}.vibn_market_data.software_providers_seo\` WHERE domain = @domain LIMIT 1`;
const [rows] = await bigquery.query({ query, params: { domain } });
if (rows && rows.length > 0) {
const r = rows[0];
return NextResponse.json({
result: {
message: "Cache hit! Retrieved SEO insights from Vibn Data Co-op.",
domain: r.domain,
seo_summary: {
estimated_monthly_organic_traffic: r.organic_traffic,
total_organic_keywords: r.organic_keywords_count,
},
ads_summary: {
estimated_monthly_paid_traffic: r.paid_traffic,
estimated_monthly_ad_spend_usd: r.ad_spend_usd,
total_paid_keywords: r.paid_keywords_count,
top_paid_keywords:
typeof r.top_paid_keywords === "string"
? JSON.parse(r.top_paid_keywords)
: r.top_paid_keywords,
},
isCached: true,
hint: "Use this data to tell the user how much money this competitor is spending on Google Ads to acquire customers, and what keywords they are targeting.",
},
});
}
} catch (e) {}
const DFS_LOGIN = "mark@getacquired.com";
const DFS_PASSWORD = "c9893141f2ee1d50";
const DFS_AUTH =
"Basic " + Buffer.from(DFS_LOGIN + ":" + DFS_PASSWORD).toString("base64");
try {
// 2. Fetch Domain Analytics
const urlMetrics =
"https://api.dataforseo.com/v3/dataforseo_labs/domain_metrics_by_categories/live";
const payloadMetrics = [
{
target1: domain,
location_name: "United States",
language_name: "English",
},
];
const resMetrics = await fetch(urlMetrics, {
method: "POST",
headers: { Authorization: DFS_AUTH, "Content-Type": "application/json" },
body: JSON.stringify(payloadMetrics),
});
const dataMetrics = await resMetrics.json();
let organicData = { etv: 0, count: 0 };
let paidData = { etv: 0, count: 0, estimated_paid_traffic_cost: 0 };
if (
dataMetrics.status_code === 20000 &&
dataMetrics.tasks?.[0]?.result?.[0]?.items?.[0]
) {
const metrics = dataMetrics.tasks[0].result[0].items[0].metrics;
if (metrics?.organic) organicData = metrics.organic;
if (metrics?.paid) paidData = metrics.paid;
}
// 3. Fetch Top Paid Keywords
let topPaidKeywords = [];
if (paidData.count > 0) {
const urlPaid =
"https://api.dataforseo.com/v3/dataforseo_labs/ranked_keywords/live";
const payloadPaid = [
{
target: domain,
location_name: "United States",
language_name: "English",
item_types: ["paid"],
limit: 5,
},
];
const resPaid = await fetch(urlPaid, {
method: "POST",
headers: {
Authorization: DFS_AUTH,
"Content-Type": "application/json",
},
body: JSON.stringify(payloadPaid),
});
const dataPaid = await resPaid.json();
if (
dataPaid.status_code === 20000 &&
dataPaid.tasks?.[0]?.result?.[0]?.items
) {
topPaidKeywords = dataPaid.tasks[0].result[0].items
.map((i: any) => i.keyword_data?.keyword)
.filter(Boolean);
}
}
// 4. Save to BigQuery
try {
await bigquery
.dataset("vibn_market_data")
.table("software_providers_seo")
.insert([
{
domain: domain,
organic_traffic: organicData.etv || 0,
organic_keywords_count: organicData.count || 0,
paid_traffic: paidData.etv || 0,
ad_spend_usd: paidData.estimated_paid_traffic_cost || 0,
paid_keywords_count: paidData.count || 0,
top_organic_keywords: JSON.stringify([]),
top_paid_keywords: JSON.stringify(topPaidKeywords),
last_updated: bigquery.datetime(new Date().toISOString()),
},
]);
} catch (e) {
console.error("Insert into software_providers_seo failed", e);
}
return NextResponse.json({
result: {
domain: domain,
seo_summary: {
estimated_monthly_organic_traffic: organicData.etv,
total_organic_keywords: organicData.count,
},
ads_summary: {
estimated_monthly_paid_traffic: paidData.etv,
estimated_monthly_ad_spend_usd:
paidData.estimated_paid_traffic_cost || 0,
total_paid_keywords: paidData.count,
top_paid_keywords: topPaidKeywords,
},
isCached: false,
hint: "Use this data to tell the user how much money this competitor is spending on Google Ads to acquire customers, and what keywords they are targeting.",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolTechStackAnalyze(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const urls = Array.isArray(params.urls) ? params.urls : [];
const softwareCategoryId = String(params.software_category_id ?? "").trim();
const customChecks = Array.isArray(params.custom_checks)
? params.custom_checks
: [];
if (urls.length === 0 || !softwareCategoryId) {
return NextResponse.json(
{ error: "urls array and software_category_id are required" },
{ status: 400 },
);
}
const targetUrls = urls.slice(0, 100);
// 1. Dynamically load competitors from BigQuery for this category
let bqOptions = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
const credentials = JSON.parse(saStr);
bqOptions.credentials = credentials;
bqOptions.projectId = credentials.project_id;
} catch (e) {
console.error(e);
}
}
const bigquery = new BigQuery(bqOptions);
let competitors: Array<{ name: string; domain: string; id: string }> = [];
try {
const query = `SELECT id, name, website FROM \`${bqOptions.projectId}.vibn_market_data.software_providers\` WHERE software_category_id = @categoryId`;
const [rows] = await bigquery.query({
query,
params: { categoryId: softwareCategoryId },
});
competitors = rows.map((r: any) => {
let domain = "";
if (r.website) {
domain = r.website
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0]
.toLowerCase();
}
return { name: r.name, id: r.id, domain };
});
} catch (err) {
console.error("BigQuery fetch failed for competitors:", err);
}
const stats: Record<string, number> = {
WordPress: 0,
Squarespace: 0,
Wix: 0,
Shopify: 0,
Webflow: 0,
"Google Analytics": 0,
"Facebook Pixel": 0,
Stripe: 0,
};
// Initialize stats for dynamic competitors & custom checks
competitors.forEach((c) => {
stats[c.name] = 0;
});
customChecks.forEach((c) => {
stats[String(c)] = 0;
});
let successCount = 0;
let failedCount = 0;
// Batch process 10 at a time
for (let i = 0; i < targetUrls.length; i += 10) {
const batch = targetUrls.slice(i, i + 10);
await Promise.all(
batch.map(async (domainStr) => {
const url = domainStr.startsWith("http")
? domainStr
: `https://${domainStr}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
const res = await fetch(url, {
headers: { "User-Agent": "Vibn-AI-Market-Scanner/1.0" },
signal: controller.signal,
});
clearTimeout(timeoutId);
const html = await res.text();
const headers = JSON.stringify(
Object.fromEntries(res.headers.entries()),
);
let fullSource = (html + " " + headers).toLowerCase();
// INTELLIGENT SPIDER: Extract high-intent links from the homepage
const linkRegex = /<a[^>]+href=["']([^"']+)["']/gi;
let match;
const subUrls = [];
while ((match = linkRegex.exec(html)) !== null) {
const href = match[1].toLowerCase();
if (
href.includes("book") ||
href.includes("schedule") ||
href.includes("appointment") ||
href.includes("contact") ||
href.includes("login") ||
href.includes("portal")
) {
try {
const subUrl = new URL(match[1], url).href;
if (!subUrls.includes(subUrl) && subUrls.length < 2) {
// Limit to top 2 links to keep it fast
subUrls.push(subUrl);
}
} catch (e) {}
}
}
// Fetch subpages if found
for (const subUrl of subUrls) {
try {
const subRes = await fetch(subUrl, {
headers: { "User-Agent": "Vibn-AI-Market-Scanner/1.0" },
signal: AbortSignal.timeout(3000),
});
const subHtml = await subRes.text();
fullSource += " " + subHtml.toLowerCase();
} catch (e) {}
}
// 1. Universal CMS/Infra
if (
fullSource.includes("wp-content") ||
fullSource.includes("wordpress")
)
stats["WordPress"]++;
if (fullSource.includes("squarespace.com")) stats["Squarespace"]++;
if (fullSource.includes("wix.com")) stats["Wix"]++;
if (fullSource.includes("webflow.com")) stats["Webflow"]++;
if (fullSource.includes("shopify.com")) stats["Shopify"]++;
if (
fullSource.includes("googletagmanager.com") ||
fullSource.includes("gtag")
)
stats["Google Analytics"]++;
if (fullSource.includes("fbq(") || fullSource.includes("fbevents.js"))
stats["Facebook Pixel"]++;
if (fullSource.includes("stripe.com/v3")) stats["Stripe"]++;
// 2. DYNAMIC INCUMBENT DETECTION
competitors.forEach((c) => {
const hasDomain = c.domain && fullSource.includes(c.domain);
const hasId = c.id && fullSource.includes(c.id.replace(/-/g, ""));
if (hasDomain || hasId) stats[c.name]++;
});
// 3. AI-PROVIDED CUSTOM CHECKS
customChecks.forEach((check) => {
if (fullSource.includes(String(check).toLowerCase())) {
stats[String(check)]++;
}
});
successCount++;
} catch (err) {
failedCount++;
}
}),
);
}
return NextResponse.json({
result: {
urlsScanned: targetUrls.length,
successfulScans: successCount,
failedScans: failedCount,
techStackStatistics: stats,
dynamicCompetitorsChecked: competitors.map((c) => c.name),
customChecksEvaluated: customChecks,
hint: "Use this data to identify market gaps against specific incumbents.",
},
});
}
async function toolMarketCategoriesSuggest(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const niche = String(params.niche ?? "").trim();
if (!niche) {
return NextResponse.json({ error: "niche required" }, { status: 400 });
}
let bqOptions: any = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
bqOptions.credentials = JSON.parse(saStr);
bqOptions.projectId = bqOptions.credentials.project_id;
} catch (e) {
console.error(e);
}
}
const bigquery = new BigQuery(bqOptions);
try {
// We use BigQuery's built-in search to find the most relevant categories
// For a real semantic search you'd use Vertex AI embeddings, but a broad ILIKE covers 90% of use cases.
const searchTerms = niche.split(" ").filter((w) => w.length > 3);
let whereClause = "";
if (searchTerms.length > 0) {
whereClause =
"WHERE " +
searchTerms
.map(
(t) =>
`LOWER(display_name) LIKE LOWER('%' || @term${searchTerms.indexOf(t)} || '%')`,
)
.join(" OR ");
} else {
whereClause =
"WHERE LOWER(display_name) LIKE LOWER('%' || @niche || '%')";
}
const queryStr = `
SELECT gcid, display_name
FROM \`${bqOptions.projectId}.vibn_market_data.gbp_categories\`
${whereClause}
LIMIT 15
`;
const queryParams: any = { niche };
searchTerms.forEach((t, i) => {
queryParams[`term${i}`] = t;
});
const [rows] = await bigquery.query({
query: queryStr,
params: queryParams,
});
return NextResponse.json({
result: {
niche_analyzed: niche,
suggested_categories: rows.map((r: any) => ({
gcid: r.gcid,
name: r.display_name,
})),
instruction:
"Present these categories to the user. Ask them to confirm which ones best represent their target market before proceeding with market_research_run.",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolMarketCompetitorResearch(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const niche = String(params.niche ?? "").trim();
if (!niche) {
return NextResponse.json({ error: "niche required" }, { status: 400 });
}
let bqOptions: any = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
bqOptions.credentials = JSON.parse(saStr);
bqOptions.projectId = bqOptions.credentials.project_id;
} catch (e) {
console.error(e);
}
}
const bigquery = new BigQuery(bqOptions);
try {
const searchTerms = niche.split(" ").filter((w) => w.length > 2);
let whereClause = "";
if (searchTerms.length > 0) {
whereClause =
"WHERE " +
searchTerms
.map(
(t, i) =>
`(LOWER(c.name) LIKE LOWER('%' || @term${i} || '%') OR LOWER(c.id) LIKE LOWER('%' || @term${i} || '%'))`,
)
.join(" OR ");
} else {
whereClause = "WHERE LOWER(c.name) LIKE LOWER('%' || @niche || '%')";
}
// 1. Find matching software categories
const catQuery = `
SELECT id, name FROM \`${bqOptions.projectId}.vibn_market_data.software_categories\` c
${whereClause} LIMIT 5
`;
const queryParams: any = { niche };
searchTerms.forEach((t, i) => {
queryParams[`term${i}`] = t;
});
const [categories] = await bigquery.query({
query: catQuery,
params: queryParams,
});
const categoryIds = categories.map((c: any) => c.id);
if (categoryIds.length === 0) {
return NextResponse.json({
result: {
niche_analyzed: niche,
message:
"No exact software categories found for this niche in the database. Try broadening your search term.",
proprietary_competitors: [],
open_source_alternatives: [],
},
});
}
// 2. Fetch proprietary competitors
const compQuery = `
SELECT name, website, estimated_pricing, description
FROM \`${bqOptions.projectId}.vibn_market_data.software_providers\`
WHERE software_category_id IN UNNEST(@categoryIds)
LIMIT 20
`;
const [competitors] = await bigquery.query({
query: compQuery,
params: { categoryIds },
});
// 3. Fetch open source repos
const ossQuery = `
SELECT repo_name, url, description, stars, license
FROM \`${bqOptions.projectId}.vibn_market_data.open_source_repos\`
WHERE software_category_id IN UNNEST(@categoryIds)
ORDER BY stars DESC
LIMIT 10
`;
const [oss] = await bigquery.query({
query: ossQuery,
params: { categoryIds },
});
return NextResponse.json({
result: {
niche_analyzed: niche,
matched_categories: categories.map((c: any) => c.name),
proprietary_competitors: competitors,
open_source_alternatives: oss,
instruction:
"Use this data to outline the competitive landscape. For deep-dives on a specific competitor's traffic and ad spend, use the market_seo_analyze tool with their domain.",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolMarketAggregateInsights(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const category = String(params.category ?? "").trim();
let location = String(params.location ?? "").trim();
if (!category || !location) {
return NextResponse.json(
{ error: "category and location required" },
{ status: 400 },
);
}
// Init BigQuery
let bqOptions: any = {
projectId: process.env.GCP_PROJECT_ID || "master-ai-484822",
};
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) {
try {
const saStr = Buffer.from(
process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64,
"base64",
).toString("utf8");
bqOptions.credentials = JSON.parse(saStr);
bqOptions.projectId = bqOptions.credentials.project_id;
} catch (e) {}
}
const bigquery = new BigQuery(bqOptions);
// 1. Check BigQuery Cache First
try {
const query = `SELECT * FROM \`${bqOptions.projectId}.vibn_market_data.market_aggregations\` WHERE category = @category AND location_query = @location LIMIT 1`;
const [rows] = await bigquery.query({
query,
params: { category, location },
});
if (rows && rows.length > 0) {
const r = rows[0];
return NextResponse.json({
result: {
message:
"Cache hit! Retrieved aggregated insights from Vibn Data Co-op.",
total_market_size: r.total_market_size,
businesses_with_websites: r.websites_count,
businesses_without_websites:
r.total_market_size - (r.websites_count || 0),
market_sub_niches:
typeof r.sub_niches === "string"
? JSON.parse(r.sub_niches)
: r.sub_niches,
customer_review_topics:
typeof r.customer_pain_points === "string"
? JSON.parse(r.customer_pain_points)
: r.customer_pain_points,
isCached: true,
instruction:
"Use 'businesses_without_websites' to judge technical debt in the market. Use 'customer_review_topics' to extract the primary pain points that patients/customers care about, and inject those exact words into the product's marketing copy and Value Prop.",
},
});
}
} catch (e) {
console.error("Cache check failed", e);
}
// 2. Fetch from DataForSEO
const DFS_LOGIN = "mark@getacquired.com";
const DFS_PASSWORD = "c9893141f2ee1d50";
const DFS_AUTH =
"Basic " + Buffer.from(DFS_LOGIN + ":" + DFS_PASSWORD).toString("base64");
try {
const url =
"https://api.dataforseo.com/v3/business_data/business_listings/categories_aggregation/live";
const payload: any = [
{
categories: [category],
language_name: "English",
limit: 10, // Max 10 category aggregations returned
},
];
const isCoords = /^-?\d+\.\d+,-?\d+\.\d+/.test(location);
if (isCoords) {
if (location.split(",").length === 2) location += ",20";
payload[0]["location_coordinate"] = location;
} else {
payload[0]["location_name"] = location;
}
const response = await fetch(url, {
method: "POST",
headers: { Authorization: DFS_AUTH, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (data.status_code !== 20000) {
return NextResponse.json(
{
error: `DataForSEO Error: ${data.tasks?.[0]?.status_message || data.status_message}`,
},
{ status: 500 },
);
}
const result = data.tasks[0].result[0];
const primaryAgg = result.items?.find((i: any) =>
i.categories.includes(category),
)?.aggregation;
if (!primaryAgg) {
return NextResponse.json({
result: {
message:
"No aggregation data found for this category in this location.",
total_market_size: result.total_count,
},
});
}
// 3. Save to BigQuery
try {
await bigquery
.dataset("vibn_market_data")
.table("market_aggregations")
.insert([
{
category: category,
location_query: location,
total_market_size: result.total_count,
websites_count: primaryAgg.websites_count || 0,
sub_niches: JSON.stringify(primaryAgg.top_categories || {}),
customer_pain_points: JSON.stringify(
primaryAgg.top_place_topics || {},
),
last_updated: bigquery.datetime(new Date().toISOString()),
},
]);
} catch (e) {
console.error("Insert into market_aggregations failed", e);
}
return NextResponse.json({
result: {
total_market_size: result.total_count,
businesses_with_websites: primaryAgg.websites_count,
businesses_without_websites:
result.total_count - (primaryAgg.websites_count || 0),
market_sub_niches: primaryAgg.top_categories,
customer_review_topics: primaryAgg.top_place_topics,
isCached: false,
instruction:
"Use 'businesses_without_websites' to judge technical debt in the market. Use 'customer_review_topics' to extract the primary pain points that patients/customers care about, and inject those exact words into the product's marketing copy and Value Prop.",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}