7340 lines
238 KiB
TypeScript
7340 lines
238 KiB
TypeScript
/**
|
||
* 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 { 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",
|
||
"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 "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 "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 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 },
|
||
});
|
||
}
|
||
|
||
// ── 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,
|
||
});
|
||
|
||
// Instead of firing-and-forgetting, we now wait for the server to ACTUALLY
|
||
// spin up and serve HTTP traffic before we return success to the AI.
|
||
// This allows the AI to see the exact health check failure synchronously.
|
||
let isHealthy = false;
|
||
let failureOutput = "";
|
||
|
||
try {
|
||
await probeDevServerReadiness(project.id, row.id, row.port);
|
||
isHealthy = true;
|
||
} catch (probeErr: any) {
|
||
isHealthy = false;
|
||
failureOutput = probeErr.message || String(probeErr);
|
||
console.error("[dev_server.start] Synchronous probe failed:", probeErr);
|
||
}
|
||
|
||
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 },
|
||
);
|
||
}
|
||
}
|