feat(mcp): apps.create image/composeRaw pathways + apps.volumes.list/wipe
Third-party apps (Twenty, Directus, Cal.com, Plane…) should never need
a Gitea repo. This adds two new apps.create pathways:
image: "twentyhq/twenty:1.23.0" → Coolify /applications/dockerimage
composeRaw: "services:\n..." → Coolify /applications/dockercompose
No repo is created, no git clone, no PAT embedding. Agents can fetch
the official docker-compose.yml and pass it inline, or just give an
image name. Pathway 1 (repo) is unchanged.
Also adds volume management tools so agents can self-recover from the
most common compose failure (stale DB volume blocking fresh migrations):
apps.volumes.list { uuid } → list volumes + sizes
apps.volumes.wipe { uuid, volume, confirm } → stop containers,
rm volume, done
Both volume tools go through the same vibn-logs SSH channel. The wipe
tool requires confirm == volume name to prevent accidents and verifies
the volume belongs to the target app (uuid in name).
lib/coolify.ts: createDockerImageApp + createDockerComposeApp helpers,
dockerimage added to CoolifyBuildPack union.
app/api/mcp/route.ts: resolveFqdn + applyEnvsAndDeploy extracted as
shared helpers; toolAppsCreate now dispatches on image/composeRaw/repo.
toolAppsVolumesList + toolAppsVolumesWipe added.
sq() moved to module scope (shared by exec + volumes tools).
Version bumped to 2.3.0.
Made-with: Cursor
This commit is contained in:
@@ -28,7 +28,8 @@ import {
|
|||||||
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
||||||
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
|
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
|
||||||
import { execInCoolifyApp } from '@/lib/coolify-exec';
|
import { execInCoolifyApp } from '@/lib/coolify-exec';
|
||||||
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
|
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
|
||||||
|
import { listContainersForApp } from '@/lib/coolify-containers';
|
||||||
import {
|
import {
|
||||||
deployApplication,
|
deployApplication,
|
||||||
getApplicationInProject,
|
getApplicationInProject,
|
||||||
@@ -41,6 +42,8 @@ import {
|
|||||||
deleteApplicationEnv,
|
deleteApplicationEnv,
|
||||||
// Phase 4 ── create/update/delete + domains + databases + services
|
// Phase 4 ── create/update/delete + domains + databases + services
|
||||||
createPublicApp,
|
createPublicApp,
|
||||||
|
createDockerImageApp,
|
||||||
|
createDockerComposeApp,
|
||||||
updateApplication,
|
updateApplication,
|
||||||
deleteApplication,
|
deleteApplication,
|
||||||
setApplicationDomains,
|
setApplicationDomains,
|
||||||
@@ -74,7 +77,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
name: 'vibn-mcp',
|
name: 'vibn-mcp',
|
||||||
version: '2.2.0',
|
version: '2.3.0',
|
||||||
authentication: {
|
authentication: {
|
||||||
scheme: 'Bearer',
|
scheme: 'Bearer',
|
||||||
tokenPrefix: 'vibn_sk_',
|
tokenPrefix: 'vibn_sk_',
|
||||||
@@ -102,6 +105,8 @@ export async function GET() {
|
|||||||
'apps.domains.set',
|
'apps.domains.set',
|
||||||
'apps.logs',
|
'apps.logs',
|
||||||
'apps.exec',
|
'apps.exec',
|
||||||
|
'apps.volumes.list',
|
||||||
|
'apps.volumes.wipe',
|
||||||
'apps.envs.list',
|
'apps.envs.list',
|
||||||
'apps.envs.upsert',
|
'apps.envs.upsert',
|
||||||
'apps.envs.delete',
|
'apps.envs.delete',
|
||||||
@@ -198,6 +203,10 @@ export async function POST(request: Request) {
|
|||||||
return await toolAppsLogs(principal, params);
|
return await toolAppsLogs(principal, params);
|
||||||
case 'apps.exec':
|
case 'apps.exec':
|
||||||
return await toolAppsExec(principal, params);
|
return await toolAppsExec(principal, params);
|
||||||
|
case 'apps.volumes.list':
|
||||||
|
return await toolAppsVolumesList(principal, params);
|
||||||
|
case 'apps.volumes.wipe':
|
||||||
|
return await toolAppsVolumesWipe(principal, params);
|
||||||
|
|
||||||
case 'databases.list':
|
case 'databases.list':
|
||||||
return await toolDatabasesList(principal);
|
return await toolDatabasesList(principal);
|
||||||
@@ -509,6 +518,113 @@ async function toolAppsExec(principal: Principal, params: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
if (!isCoolifySshConfigured()) {
|
||||||
|
return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 });
|
||||||
|
}
|
||||||
|
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
|
||||||
|
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||||
|
await getApplicationInProject(appUuid, projectUuid);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 getApplicationInProject(appUuid, projectUuid);
|
||||||
|
|
||||||
|
// 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>) {
|
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
|
||||||
const projectUuid = requireCoolifyProject(principal);
|
const projectUuid = requireCoolifyProject(principal);
|
||||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||||
@@ -575,19 +691,98 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
|
|||||||
// Phase 4: apps create/update/delete + domains
|
// Phase 4: apps create/update/delete + domains
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apps.create — three distinct pathways depending on what you pass:
|
||||||
|
*
|
||||||
|
* 1. Gitea repo (existing behaviour — 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
|
||||||
|
*
|
||||||
|
* Pathways 2 and 3 do NOT create a Gitea repo. They deploy directly
|
||||||
|
* from Docker Hub / any public registry, or from the raw YAML you
|
||||||
|
* supply. Use these for third-party apps (Twenty, Directus, Cal.com…).
|
||||||
|
*
|
||||||
|
* Use pathway 1 for user's own code that lives in the workspace's
|
||||||
|
* Gitea org.
|
||||||
|
*/
|
||||||
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
|
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
|
||||||
const ws = principal.workspace;
|
const ws = principal.workspace;
|
||||||
if (!ws.coolify_project_uuid || !ws.gitea_org) {
|
if (!ws.coolify_project_uuid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' },
|
{ error: 'Workspace not fully provisioned (need Coolify project)' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's
|
const commonOpts = {
|
||||||
// builtin SSH is on an internal-only port and port 22 hits the host's
|
projectUuid: ws.coolify_project_uuid,
|
||||||
// OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
// the PAT is scoped to the org via team membership.
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
isForceHttpsEnabled: true,
|
||||||
|
instantDeploy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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 applyEnvsAndDeploy(created.uuid, params);
|
||||||
|
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pathway 3: Inline Docker Compose ─────────────────────────────────
|
||||||
|
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;
|
||||||
|
|
||||||
|
// composeDomains: array of { service, domain } or derive from fqdn
|
||||||
|
const composeDomains: Array<{ service: string; domain: string }> =
|
||||||
|
Array.isArray(params.composeDomains) && params.composeDomains.length > 0
|
||||||
|
? params.composeDomains
|
||||||
|
: [{ service: params.composeService ?? 'server', domain: fqdn }];
|
||||||
|
|
||||||
|
const created = await createDockerComposeApp({
|
||||||
|
...commonOpts,
|
||||||
|
composeRaw,
|
||||||
|
name: appName,
|
||||||
|
description: params.description ? String(params.description) : undefined,
|
||||||
|
composeDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyEnvsAndDeploy(created.uuid, params);
|
||||||
|
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 `image` or `composeRaw` instead of `repo`.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const botCreds = getWorkspaceBotCredentials(ws);
|
const botCreds = getWorkspaceBotCredentials(ws);
|
||||||
if (!botCreds) {
|
if (!botCreds) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -597,7 +792,12 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repoIn = String(params.repo ?? '').trim();
|
const repoIn = String(params.repo ?? '').trim();
|
||||||
if (!repoIn) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 });
|
if (!repoIn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'One of `repo`, `image`, or `composeRaw` is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parts = repoIn.replace(/\.git$/, '').split('/');
|
const parts = repoIn.replace(/\.git$/, '').split('/');
|
||||||
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
|
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
|
||||||
@@ -614,21 +814,11 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appName = slugify(String(params.name ?? repoName));
|
const appName = slugify(String(params.name ?? repoName));
|
||||||
const fqdn = String(params.domain ?? '').trim()
|
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
|
||||||
? String(params.domain).replace(/^https?:\/\//, '')
|
if (fqdn instanceof NextResponse) return fqdn;
|
||||||
: workspaceAppFqdn(ws.slug, appName);
|
|
||||||
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await createPublicApp({
|
const created = await createPublicApp({
|
||||||
projectUuid: ws.coolify_project_uuid,
|
...commonOpts,
|
||||||
serverUuid: ws.coolify_server_uuid ?? undefined,
|
|
||||||
environmentName: ws.coolify_environment_name,
|
|
||||||
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
|
||||||
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
|
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
|
||||||
gitBranch: String(params.branch ?? repo.default_branch ?? 'main'),
|
gitBranch: String(params.branch ?? repo.default_branch ?? 'main'),
|
||||||
portsExposes: String(params.ports ?? '3000'),
|
portsExposes: String(params.ports ?? '3000'),
|
||||||
@@ -636,50 +826,62 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
name: appName,
|
name: appName,
|
||||||
domains: toDomainsString([fqdn]),
|
domains: toDomainsString([fqdn]),
|
||||||
isAutoDeployEnabled: true,
|
isAutoDeployEnabled: true,
|
||||||
isForceHttpsEnabled: true,
|
dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined,
|
||||||
instantDeploy: false,
|
dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined,
|
||||||
dockerComposeLocation: params.dockerComposeLocation
|
|
||||||
? String(params.dockerComposeLocation)
|
|
||||||
: undefined,
|
|
||||||
dockerfileLocation: params.dockerfileLocation
|
|
||||||
? String(params.dockerfileLocation)
|
|
||||||
: undefined,
|
|
||||||
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
|
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach envs
|
const dep = await applyEnvsAndDeploy(created.uuid, params);
|
||||||
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(created.uuid, { key: k, value: String(v) });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[mcp apps.create] upsert env failed', k, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let deploymentUuid: string | null = null;
|
|
||||||
if (params.instantDeploy !== false) {
|
|
||||||
try {
|
|
||||||
const dep = await deployApplication(created.uuid);
|
|
||||||
deploymentUuid = dep.deployment_uuid ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[mcp apps.create] first deploy failed', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
result: {
|
result: {
|
||||||
uuid: created.uuid,
|
uuid: created.uuid,
|
||||||
name: appName,
|
name: appName,
|
||||||
domain: fqdn,
|
domain: fqdn,
|
||||||
url: `https://${fqdn}`,
|
url: `https://${fqdn}`,
|
||||||
deploymentUuid,
|
deploymentUuid: dep,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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>) {
|
async function toolAppsUpdate(principal: Principal, params: Record<string, any>) {
|
||||||
const projectUuid = requireCoolifyProject(principal);
|
const projectUuid = requireCoolifyProject(principal);
|
||||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export async function restartDatabase(uuid: string): Promise<void> {
|
|||||||
// Applications
|
// Applications
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose';
|
export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose' | 'dockerimage';
|
||||||
|
|
||||||
export interface CreatePrivateDeployKeyAppOpts {
|
export interface CreatePrivateDeployKeyAppOpts {
|
||||||
projectUuid: string;
|
projectUuid: string;
|
||||||
@@ -455,6 +455,92 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Repo-free app creation (Docker image / raw compose)
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateDockerImageAppOpts {
|
||||||
|
projectUuid: string;
|
||||||
|
image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine"
|
||||||
|
name?: string;
|
||||||
|
portsExposes?: string; // default "80"
|
||||||
|
domains?: string;
|
||||||
|
description?: string;
|
||||||
|
serverUuid?: string;
|
||||||
|
environmentName?: string;
|
||||||
|
destinationUuid?: string;
|
||||||
|
isForceHttpsEnabled?: boolean;
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDockerImageApp(
|
||||||
|
opts: CreateDockerImageAppOpts,
|
||||||
|
): Promise<{ uuid: string }> {
|
||||||
|
const body = stripUndefined({
|
||||||
|
project_uuid: opts.projectUuid,
|
||||||
|
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
|
||||||
|
environment_name: opts.environmentName ?? 'production',
|
||||||
|
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
|
||||||
|
docker_registry_image_name: opts.image,
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
ports_exposes: opts.portsExposes ?? '80',
|
||||||
|
domains: opts.domains,
|
||||||
|
is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
|
||||||
|
instant_deploy: opts.instantDeploy ?? false,
|
||||||
|
});
|
||||||
|
return coolifyFetch('/applications/dockerimage', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDockerComposeAppOpts {
|
||||||
|
projectUuid: string;
|
||||||
|
composeRaw: string; // raw docker-compose YAML as a string
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
serverUuid?: string;
|
||||||
|
environmentName?: string;
|
||||||
|
destinationUuid?: string;
|
||||||
|
isForceHttpsEnabled?: boolean;
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
/**
|
||||||
|
* Map compose service(s) to public domain(s) after creation.
|
||||||
|
* Array of { service, domain } pairs. The first entry becomes the
|
||||||
|
* primary public URL.
|
||||||
|
*/
|
||||||
|
composeDomains?: Array<{ service: string; domain: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDockerComposeApp(
|
||||||
|
opts: CreateDockerComposeAppOpts,
|
||||||
|
): Promise<{ uuid: string }> {
|
||||||
|
const body = stripUndefined({
|
||||||
|
project_uuid: opts.projectUuid,
|
||||||
|
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
|
||||||
|
environment_name: opts.environmentName ?? 'production',
|
||||||
|
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
|
||||||
|
build_pack: 'dockercompose',
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
docker_compose_raw: opts.composeRaw,
|
||||||
|
is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
|
||||||
|
instant_deploy: opts.instantDeploy ?? false,
|
||||||
|
// domains for compose are set via docker_compose_domains after creation
|
||||||
|
docker_compose_domains: opts.composeDomains
|
||||||
|
? JSON.stringify(opts.composeDomains.map(({ service, domain }) => ({
|
||||||
|
name: service,
|
||||||
|
domain: `https://${domain.replace(/^https?:\/\//, '')}`,
|
||||||
|
})))
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return coolifyFetch('/applications/dockercompose', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateApplication(
|
export async function updateApplication(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
patch: Record<string, unknown>
|
patch: Record<string, unknown>
|
||||||
|
|||||||
Reference in New Issue
Block a user