fix: rolling deploys + service custom-domain support

Two product gaps surfaced from the twenty-live debugging session:

1. Vibn frontend now has a healthcheck on / port 3000. Coolify will
   wait for the new container to be healthy before swapping traffic,
   so deploys no longer drop in-flight chat SSE streams. (Setting was
   applied via Coolify API; commit just documents.)

2. apps_domains_set now handles SERVICES (template-based apps like
   Twenty CRM, n8n) — not just applications. Setting service_apps.fqdn
   in the DB alone gets reverted by Coolify's deploy pipeline, so we
   replicate the Livewire EditDomain.php save flow via tinker over SSH:
   write fqdn → save → updateCompose() → service.parse(). After this
   apps_deploy regenerates Traefik labels with the custom domain.
   Auto-detects service vs application by uuid lookup. New { port }
   parameter lets the AI pin the upstream port for services that
   require one (Coolify hard-fails the save without it).

Tool description rewritten with the new behavior + a worked example
so the AI uses the right pipeline first try.

Made-with: Cursor
This commit is contained in:
2026-04-30 11:28:25 -07:00
parent eb4086d296
commit bd993123c0
2 changed files with 93 additions and 6 deletions

View File

@@ -2256,7 +2256,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
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;
@@ -2269,13 +2269,82 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
}
normalized.push(clean);
}
const buildPack = (app.build_pack ?? 'nixpacks') as string;
// Try service first — services (template-based, e.g. Twenty CRM) have
// a totally different domain mechanism than plain applications. The
// ServiceApplication.fqdn field is the source of truth, but writing
// it directly without invoking Coolify's updateCompose() + parse()
// pipeline gets reverted on next deploy. Replicate the Livewire
// EditDomain.php save flow via tinker so Traefik labels actually
// regenerate.
const composeService =
typeof params.service === 'string' && params.service.trim()
? params.service.trim()
: typeof params.composeService === 'string' && params.composeService.trim()
? params.composeService.trim()
: undefined;
let serviceFound = false;
try {
const svc = await getServiceInWorkspace(appUuid, principal.workspace);
if (svc) serviceFound = true;
} catch {}
if (serviceFound) {
if (!isCoolifySshConfigured()) {
return NextResponse.json({
error: 'Setting custom domains on services requires SSH-managed Coolify (not configured on this deploy).',
}, { status: 503 });
}
// Pick the inner app to wire the domain to. Default = first
// non-worker / non-job app, mirroring Coolify UI defaults.
const inner = composeService ?? 'auto';
// Ensure the user-provided FQDN includes a port — Coolify hard-fails
// the save if a template exposes a required port and we don't pin it.
// Default to the template's required port when known.
const port = params.port ? Number(params.port) : null;
const fqdnsForUrl = normalized
.map((d) => `https://${d}${port ? `:${port}` : ''}`)
.join(',');
// PHP code: load service, find target inner app by name (or auto-pick),
// set fqdn, save, regenerate compose, re-parse. All quoting carefully
// escaped because we shell this through ssh.
const phpCode = `
$service = App\\Models\\Service::where('uuid', '${appUuid}')->first();
if (!$service) { echo 'service-not-found'; exit; }
$apps = $service->applications()->get();
$target = ${inner === 'auto' ? '$apps->first(fn($a) => !str_contains(strtolower($a->name), \'worker\') && !str_contains(strtolower($a->name), \'job\')) ?? $apps->first()' : `$apps->firstWhere('name', '${inner}')`};
if (!$target) { echo 'inner-app-not-found'; exit; }
$target->fqdn = '${fqdnsForUrl}';
$target->save();
updateCompose($target);
$service->parse();
echo 'fqdn-saved=' . $target->fresh()->fqdn;
`;
const result = await runOnCoolifyHost(
`docker exec coolify php artisan tinker --execute=${shellEscape(phpCode)}`
);
const out = (result.stdout || '').trim();
if (out.includes('service-not-found') || out.includes('inner-app-not-found')) {
return NextResponse.json({
error: out,
stderr: result.stderr,
}, { status: 404 });
}
return NextResponse.json({
result: {
uuid: appUuid,
kind: 'service',
domains: normalized,
innerApp: inner,
tinkerOut: out,
summaryHint: `Custom domain saved on service ${appUuid}. Now call apps_deploy { uuid: "${appUuid}" } to regenerate Traefik labels and bring the new domain live.`,
},
});
}
const app = await getApplicationInWorkspace(appUuid, ownedUuids);
const buildPack = (app.build_pack ?? 'nixpacks') as string;
await setApplicationDomains(appUuid, normalized, {
forceOverride: true,
buildPack,
@@ -2284,6 +2353,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
return NextResponse.json({
result: {
uuid: appUuid,
kind: 'application',
domains: normalized,
buildPack,
routedTo:
@@ -2294,6 +2364,13 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
});
}
// Minimal POSIX-shell single-quote escape for arbitrary string content.
// PHP code can contain any character except a literal `'`; we wrap in
// single quotes and break out for embedded singles.
function shellEscape(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
// ──────────────────────────────────────────────────
// Phase 4: databases
// ──────────────────────────────────────────────────

View File

@@ -304,17 +304,27 @@ Pass wipeVolumes: true ONLY if the user explicitly said "nuke the data".`,
},
{
name: 'apps_domains_set',
description: 'Replace the domain set for an application. All entries must end with .{workspace}.vibnai.com. For compose apps, use the service parameter to target a specific service.',
description: `Set the public domain(s) for an application or service. All entries must end with .{workspace}.vibnai.com.
Auto-detects whether uuid points to an application (Dockerfile / nixpacks / docker-image / compose buildpack) or a service (template-based, e.g. Twenty CRM, n8n) and uses the right Coolify pipeline for each:
- Application: writes to applications.fqdn (or docker_compose_domains for compose buildpack).
- Service: writes to service_applications.fqdn AND triggers Coolify's updateCompose() + service.parse() so Traefik labels regenerate. Without this dance, the change gets reverted on next deploy. We learned this the hard way with twenty-live.
For services with a required port (twenty-crm uses 3000), pass { port: 3000 } so the saved fqdn is "https://host:3000" — Coolify hard-fails the save otherwise. Look up the required port via apps_templates_search if you don't know it.
After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Traefik labels.`,
parameters: {
type: 'OBJECT',
properties: {
uuid: { type: 'STRING', description: 'The Coolify application UUID.' },
uuid: { type: 'STRING', description: 'The Coolify application or service UUID.' },
domains: {
type: 'ARRAY',
description: 'Array of domain strings (e.g. ["myapp.mark.vibnai.com", "api.mark.vibnai.com"]).',
description: 'Array of domain strings (e.g. ["myapp.mark.vibnai.com"]).',
items: { type: 'STRING' },
},
service: { type: 'STRING', description: 'For compose apps: the service to attach the domain to (e.g. "server"). Default: "server".' },
service: { type: 'STRING', description: 'For compose apps OR services: the inner app/service name to attach the domain to (e.g. "server", "twenty"). Default: auto-pick first non-worker app.' },
port: { type: 'NUMBER', description: 'Required for services that need a fixed upstream port (Twenty CRM = 3000, n8n = 5678, Ghost = 2368). Look up via apps_templates_search.' },
},
required: ['uuid', 'domains'],
},