Completes the rest of the Path B tool surface:
- dev_server.{start,stop,list,logs}: nohup processes inside the dev
container, track PID/port/preview-url in fs_dev_servers. Each gets
a randomized preview subdomain (preview.vibnai.com base; Traefik
wildcard wiring is staged in /vibn-dev/PREVIEWS.md but the Coolify
compose hot-update step is deferred — see file for the recommended
pre-allocated-port-range approach).
- ship: git init (if needed) -> add/commit/push to the project's
Gitea repo via the workspace bot PAT, then triggers a Coolify
production deploy if the project is linked to one. Returns push
output + deployment_uuid.
- /api/admin/path-b/autosave [POST { projectId | sweep:true }]:
force-pushes /workspace to vibn-autosave/main in Gitea. Throttled
to once per 5 min per project. Records every push in fs_dev_autosaves
for audit. Treat Gitea as canonical, container disk as ephemeral.
- /api/admin/path-b/idle-sweep [POST?minutes=30]: suspends every
running dev container whose last_active_at is older than `minutes`.
Wire to a 5-min cron. Idempotent.
- Compose template hardened: pull_policy: never (use locally-built
image, no registry round-trip) + per-project bridge network
(vibn-dev-net-<slug>) so dev containers can't reach internal Vibn
services.
- vibn-dev/setup-on-coolify.sh: one-shot script to build vibn-dev:latest
on the Coolify host. Run before first chat session uses Path B.
- vibn-tools.ts: dev_server_{start,stop,list,logs} + ship Gemini tool
defs added. Smoke test passes — 68 tool definitions accepted.
- MCP version 2.5.0 -> 2.6.0 so /api/mcp tells us when the new build
is live.
Plan doc updated to reflect what shipped vs what's still manual
(DNS wildcard, Traefik cert, build-on-host script run, gitea_file_*
hard-remove deferred to allow A/B).
Made-with: Cursor
3504 lines
134 KiB
TypeScript
3504 lines
134 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 { 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 { execInCoolifyApp } from '@/lib/coolify-exec';
|
||
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
|
||
import {
|
||
ensureDevContainer,
|
||
execInDevContainer,
|
||
getDevContainerStatus,
|
||
suspendDevContainer,
|
||
startDevServer,
|
||
stopDevServer,
|
||
listDevServers,
|
||
tailDevServerLog,
|
||
autosaveWorkspace,
|
||
} from '@/lib/dev-container';
|
||
import { isPathBDisabled } from '@/lib/feature-flags';
|
||
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,
|
||
createDockerImageApp,
|
||
createDockerComposeApp,
|
||
startService,
|
||
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.6.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',
|
||
'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',
|
||
'fs.list',
|
||
'fs.delete',
|
||
'fs.glob',
|
||
'fs.grep',
|
||
'dev_server.start',
|
||
'dev_server.stop',
|
||
'dev_server.list',
|
||
'dev_server.logs',
|
||
'ship',
|
||
],
|
||
},
|
||
},
|
||
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;
|
||
|
||
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>;
|
||
|
||
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 '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':
|
||
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.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':
|
||
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':
|
||
return await toolFsRead(principal, params);
|
||
case 'fs.write':
|
||
return await toolFsWrite(principal, params);
|
||
case 'fs.edit':
|
||
return await toolFsEdit(principal, params);
|
||
case 'fs.list':
|
||
return await toolFsList(principal, params);
|
||
case 'fs.delete':
|
||
return await toolFsDelete(principal, params);
|
||
case 'fs.glob':
|
||
return await toolFsGlob(principal, params);
|
||
case 'fs.grep':
|
||
return await toolFsGrep(principal, params);
|
||
|
||
case 'dev_server.start':
|
||
return await toolDevServerStart(principal, params);
|
||
case 'dev_server.stop':
|
||
return await toolDevServerStop(principal, params);
|
||
case 'dev_server.list':
|
||
return await toolDevServerList(principal, params);
|
||
case 'dev_server.logs':
|
||
return await toolDevServerLogs(principal, params);
|
||
case 'ship':
|
||
return await toolShip(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
|
||
}
|
||
}
|
||
|
||
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,
|
||
createdAt: r.created_at,
|
||
updatedAt: r.updated_at,
|
||
},
|
||
});
|
||
}
|
||
|
||
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 } });
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// 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 });
|
||
}
|
||
|
||
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;
|
||
|
||
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;
|
||
|
||
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;
|
||
|
||
const 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,
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
if (params.instantDeploy === false) return null;
|
||
try {
|
||
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 app = await getApplicationInWorkspace(appUuid, ownedUuids);
|
||
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);
|
||
}
|
||
const buildPack = (app.build_pack ?? 'nixpacks') as string;
|
||
const composeService =
|
||
typeof params.service === 'string' && params.service.trim()
|
||
? params.service.trim()
|
||
: typeof params.composeService === 'string' && params.composeService.trim()
|
||
? params.composeService.trim()
|
||
: undefined;
|
||
await setApplicationDomains(appUuid, normalized, {
|
||
forceOverride: true,
|
||
buildPack,
|
||
composeService,
|
||
});
|
||
return NextResponse.json({
|
||
result: {
|
||
uuid: appUuid,
|
||
domains: normalized,
|
||
buildPack,
|
||
routedTo:
|
||
buildPack === 'dockercompose'
|
||
? { field: 'docker_compose_domains', service: composeService ?? 'server' }
|
||
: { field: 'domains' },
|
||
},
|
||
});
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// 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;
|
||
const projectUuid = requireCoolifyProject(principal);
|
||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||
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)}`));
|
||
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) {
|
||
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 });
|
||
}
|
||
|
||
// 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,
|
||
cwd: typeof params.cwd === 'string' ? params.cwd : undefined,
|
||
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(p: string): string | NextResponse {
|
||
if (!p || typeof p !== 'string') {
|
||
return NextResponse.json({ error: 'Param "path" is required' }, { status: 400 });
|
||
}
|
||
let abs: string;
|
||
if (p.startsWith('/')) {
|
||
abs = p;
|
||
} else {
|
||
abs = `${FS_ROOT}/${p}`.replace(/\/+/g, '/');
|
||
}
|
||
// Disallow .. traversal that escapes /workspace.
|
||
const norm = abs.replace(/\/[^/]+\/\.\.(?=\/|$)/g, '').replace(/\/+/g, '/');
|
||
if (!norm.startsWith(FS_ROOT) && norm !== FS_ROOT) {
|
||
return NextResponse.json(
|
||
{ error: `Path "${p}" is outside ${FS_ROOT}; use shell.exec for system paths.` },
|
||
{ status: 400 },
|
||
);
|
||
}
|
||
return norm;
|
||
}
|
||
|
||
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 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 ?? ''));
|
||
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 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 ?? ''));
|
||
if (path instanceof NextResponse) return path;
|
||
const content = typeof params.content === 'string' ? params.content : '';
|
||
|
||
// Stream content via base64 to avoid shell-quoting headaches with
|
||
// arbitrary binary / multibyte input.
|
||
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
||
const cmd =
|
||
`mkdir -p ${shq(path.replace(/\/[^/]+$/, '') || FS_ROOT)} && ` +
|
||
`printf %s ${shq(b64)} | base64 -d > ${shq(path)}`;
|
||
const r = await runFsCmd(principal, project, cmd);
|
||
if (r.code !== 0) {
|
||
return NextResponse.json(
|
||
{ error: `fs.write failed: ${r.stderr.trim() || 'unknown error'}` },
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
return NextResponse.json({
|
||
result: { path, bytesWritten: Buffer.byteLength(content, 'utf8') },
|
||
});
|
||
}
|
||
|
||
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 ?? ''));
|
||
if (path instanceof NextResponse) return path;
|
||
const oldString = typeof params.oldString === 'string' ? params.oldString : '';
|
||
const newString = typeof params.newString === 'string' ? params.newString : '';
|
||
const replaceAll = Boolean(params.replaceAll);
|
||
if (!oldString) {
|
||
return NextResponse.json({ error: 'Param "oldString" is required' }, { status: 400 });
|
||
}
|
||
|
||
// Read → in-memory replace → write back. Done in one shell pipeline
|
||
// via a small embedded Python (always present on the base image)
|
||
// because doing this with sed is a quoting nightmare. The script
|
||
// bails non-zero if oldString is missing or non-unique (Aider-style).
|
||
const payload = {
|
||
path,
|
||
oldString,
|
||
newString,
|
||
replaceAll,
|
||
};
|
||
const py = `import json,sys
|
||
spec=json.loads(sys.stdin.read())
|
||
with open(spec['path'],'r',encoding='utf-8') as f: src=f.read()
|
||
old=spec['oldString']; new=spec['newString']; ra=spec['replaceAll']
|
||
n=src.count(old)
|
||
if n==0:
|
||
sys.stderr.write('oldString not found'); sys.exit(2)
|
||
if n>1 and not ra:
|
||
sys.stderr.write(f'oldString found {n}x; pass replaceAll=true or include more context'); sys.exit(3)
|
||
out=src.replace(old,new) if ra else src.replace(old,new,1)
|
||
with open(spec['path'],'w',encoding='utf-8') as f: f.write(out)
|
||
print(n)`;
|
||
|
||
const b64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
||
const pyB64 = Buffer.from(py, 'utf8').toString('base64');
|
||
const cmd =
|
||
`python3 -c "$(printf %s ${shq(pyB64)} | base64 -d)" <<< "$(printf %s ${shq(b64)} | base64 -d)"`;
|
||
|
||
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 },
|
||
);
|
||
}
|
||
return NextResponse.json({
|
||
result: { path, replacements: parseInt(r.stdout.trim() || '0', 10) },
|
||
});
|
||
}
|
||
|
||
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 ?? '/workspace'));
|
||
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 ?? ''));
|
||
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 cwd = normalizeFsPath(String(params.cwd ?? '/workspace'));
|
||
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 ?? '');
|
||
if (!pattern) {
|
||
return NextResponse.json({ error: 'Param "pattern" is required' }, { status: 400 });
|
||
}
|
||
const cwd = normalizeFsPath(String(params.cwd ?? '/workspace'));
|
||
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();
|
||
const port = Number(params.port);
|
||
if (!command || !Number.isFinite(port) || port < 1 || port > 65535) {
|
||
return NextResponse.json(
|
||
{ error: 'Params "command" (string) and "port" (1-65535) are required' },
|
||
{ status: 400 },
|
||
);
|
||
}
|
||
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,
|
||
});
|
||
return NextResponse.json({
|
||
result: {
|
||
id: row.id,
|
||
name: row.name,
|
||
port: row.port,
|
||
pid: row.pid,
|
||
previewUrl: row.preview_url,
|
||
state: row.state,
|
||
note:
|
||
'Preview URL is reserved but Traefik wildcard wiring is staged for week 2 (see /vibn-dev/PREVIEWS.md). ' +
|
||
'In the meantime, the server is reachable from inside the container at http://localhost:' +
|
||
row.port +
|
||
' — use shell.exec curl to verify it boots.',
|
||
},
|
||
});
|
||
} catch (err) {
|
||
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`;
|
||
|
||
const cmd = `set -e
|
||
cd /workspace
|
||
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`;
|
||
|
||
let pushOutput = '';
|
||
try {
|
||
const r = await execInDevContainer({
|
||
projectId: project.id,
|
||
command: cmd,
|
||
timeoutMs: 60_000,
|
||
});
|
||
pushOutput = (r.stdout + r.stderr).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 },
|
||
);
|
||
}
|
||
|
||
// Trigger Coolify deploy if the project is linked to one.
|
||
let deploymentUuid: string | null = null;
|
||
const linkedAppUuid =
|
||
typeof project.data?.coolifyAppUuid === 'string' && project.data.coolifyAppUuid.trim()
|
||
? project.data.coolifyAppUuid.trim()
|
||
: 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;
|
||
} catch (err) {
|
||
return NextResponse.json({
|
||
result: {
|
||
pushed: true,
|
||
pushOutput,
|
||
deploymentTriggered: false,
|
||
deployError: err instanceof Error ? err.message : String(err),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
result: {
|
||
repo,
|
||
branch,
|
||
message,
|
||
pushed: true,
|
||
pushOutput,
|
||
deploymentTriggered: Boolean(deploymentUuid),
|
||
deploymentUuid,
|
||
hint: deploymentUuid
|
||
? 'Deploy in progress; poll apps_deployments to track.'
|
||
: linkedAppUuid
|
||
? 'Deploy was skipped (deploy=false).'
|
||
: 'No Coolify app linked to this project yet — call apps_create to wire one up before the next ship.',
|
||
},
|
||
});
|
||
}
|