feat(mcp v2.4): apps.create template pathway + apps.templates.{list,search}
Adds Coolify one-click template support — 320+ vetted apps deployable
in one MCP call (Twenty, n8n, Supabase, Ghost, etc).
- apps.create gains a 4th pathway: { template: "<slug>", ... }. Auto-
rewrites the Coolify-assigned sslip URL to the workspace FQDN and
applies user envs before starting.
- apps.templates.list / apps.templates.search expose the catalog so
agents can discover slugs. Catalog is fetched from upstream GitHub
and cached in-memory for 1h.
- lib/coolify.ts: + setServiceDomains, updateService, listService-
Templates, searchServiceTemplates. Reuses existing createService.
- next.config.ts: externalize ssh2 + cpu-features from turbopack so
`next build` can complete (native .node binaries can't be ESM-bundled).
Made-with: Cursor
This commit is contained in:
151
lib/coolify.ts
151
lib/coolify.ts
@@ -875,6 +875,157 @@ export async function createService(opts: {
|
||||
return coolifyFetch('/services', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a Coolify Service with arbitrary fields. Used for things like
|
||||
* `urls` (custom domains), `name`, `description`, etc.
|
||||
*/
|
||||
export async function updateService(
|
||||
uuid: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<{ uuid: string }> {
|
||||
return coolifyFetch(`/services/${uuid}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(stripUndefined(patch)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom URLs on a Coolify Service. Coolify auto-assigns sslip.io
|
||||
* domains at service-creation time; this PATCH replaces them with the
|
||||
* caller-supplied FQDN.
|
||||
*
|
||||
* Each entry in `urls` maps a compose service name (the YAML key) to a
|
||||
* full URL. For most one-click templates the primary HTTP service is
|
||||
* named after the template slug (e.g. template "twenty" → service
|
||||
* "twenty"). If a template uses a different name, callers should pass
|
||||
* the explicit mapping.
|
||||
*
|
||||
* Reasoning behind the shape: Coolify expects
|
||||
* { urls: [{ name: "twenty", url: "https://my.example.com" }, ...] }
|
||||
* where `name` is the docker-compose service key. Passing only `url`
|
||||
* silently no-ops.
|
||||
*/
|
||||
export async function setServiceDomains(
|
||||
uuid: string,
|
||||
urls: Array<{ name: string; url: string }>,
|
||||
): Promise<{ uuid: string }> {
|
||||
const normalized = urls.map(u => ({
|
||||
name: u.name,
|
||||
url: /^https?:\/\//i.test(u.url.trim()) ? u.url.trim() : `https://${u.url.trim()}`,
|
||||
}));
|
||||
return updateService(uuid, { urls: normalized });
|
||||
}
|
||||
|
||||
// ── Coolify Service Templates (one-click catalog) ────────────────────
|
||||
//
|
||||
// Coolify maintains a curated catalog of 320+ deployable services
|
||||
// (CRMs, AI tools, CMSes, dashboards, databases, etc). Each entry has
|
||||
// a slug ("twenty", "n8n", "supabase"), a slogan, tags, a logo path
|
||||
// and a base64-encoded docker-compose.yml.
|
||||
//
|
||||
// Coolify itself does NOT expose this catalog over its REST API — the
|
||||
// Laravel helper `get_service_templates()` reads it directly from
|
||||
// `/var/www/html/templates/service-templates.json`. To avoid SSH-ing
|
||||
// into the Coolify host every time, we fetch the canonical JSON from
|
||||
// the upstream repo and cache it in memory for an hour.
|
||||
//
|
||||
// Source of truth:
|
||||
// https://github.com/coollabsio/coolify/blob/main/templates/service-templates.json
|
||||
|
||||
export interface CoolifyServiceTemplate {
|
||||
/** Slug used as the `type` in `POST /services` (e.g. "twenty"). */
|
||||
slug: string;
|
||||
/** One-line marketing description from upstream. */
|
||||
slogan?: string;
|
||||
/** Free-form tags ("crm", "ai", "wiki", …). */
|
||||
tags?: string[];
|
||||
/** Coarse category (often missing). */
|
||||
category?: string;
|
||||
/** Path under coollabsio/coolify-static/svgs (no full URL). */
|
||||
logo?: string;
|
||||
/** Default HTTP port the primary service listens on. */
|
||||
port?: number;
|
||||
/** Documentation URL provided by the template author. */
|
||||
documentation?: string;
|
||||
/** Minimum Coolify version required. */
|
||||
minversion?: string;
|
||||
}
|
||||
|
||||
const TEMPLATES_URL =
|
||||
process.env.COOLIFY_TEMPLATES_URL ??
|
||||
'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json';
|
||||
|
||||
let templatesCache: { fetchedAt: number; data: Record<string, CoolifyServiceTemplate> } | null = null;
|
||||
const TEMPLATES_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Fetch the full Coolify service template catalog. In-memory cache for
|
||||
* one hour because the upstream JSON is ~1MB and only changes when
|
||||
* Coolify ships new templates.
|
||||
*/
|
||||
export async function listServiceTemplates(opts: { force?: boolean } = {}): Promise<Record<string, CoolifyServiceTemplate>> {
|
||||
const now = Date.now();
|
||||
if (!opts.force && templatesCache && now - templatesCache.fetchedAt < TEMPLATES_TTL_MS) {
|
||||
return templatesCache.data;
|
||||
}
|
||||
const res = await fetch(TEMPLATES_URL, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch service templates: ${res.status} ${res.statusText}`);
|
||||
const raw = await res.json() as Record<string, Record<string, unknown>>;
|
||||
const data: Record<string, CoolifyServiceTemplate> = {};
|
||||
for (const [slug, t] of Object.entries(raw)) {
|
||||
data[slug] = {
|
||||
slug,
|
||||
slogan: typeof t.slogan === 'string' ? t.slogan : undefined,
|
||||
tags: Array.isArray(t.tags) ? t.tags.filter((x): x is string => typeof x === 'string') : undefined,
|
||||
category: typeof t.category === 'string' ? t.category : undefined,
|
||||
logo: typeof t.logo === 'string' ? t.logo : undefined,
|
||||
port: typeof t.port === 'number' ? t.port : undefined,
|
||||
documentation: typeof t.documentation === 'string' ? t.documentation : undefined,
|
||||
minversion: typeof t.minversion === 'string' ? t.minversion : undefined,
|
||||
};
|
||||
}
|
||||
templatesCache = { fetchedAt: now, data };
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the template catalog by slug, slogan, or tag (case-insensitive
|
||||
* substring match). Returns at most `limit` matches sorted by:
|
||||
* 1. exact slug match
|
||||
* 2. slug starts-with
|
||||
* 3. tag match
|
||||
* 4. slogan substring
|
||||
*/
|
||||
export async function searchServiceTemplates(
|
||||
query: string,
|
||||
opts: { limit?: number; tag?: string } = {},
|
||||
): Promise<CoolifyServiceTemplate[]> {
|
||||
const all = await listServiceTemplates();
|
||||
const q = query.trim().toLowerCase();
|
||||
const tagFilter = opts.tag?.trim().toLowerCase();
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
|
||||
|
||||
const scored: Array<{ score: number; t: CoolifyServiceTemplate }> = [];
|
||||
for (const t of Object.values(all)) {
|
||||
if (tagFilter && !(t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter))) continue;
|
||||
let score = 0;
|
||||
const slug = t.slug.toLowerCase();
|
||||
const slogan = (t.slogan ?? '').toLowerCase();
|
||||
const tags = (t.tags ?? []).map(x => x.toLowerCase());
|
||||
if (!q) {
|
||||
score = 1; // tag-only filter — include everything that passed
|
||||
} else if (slug === q) score = 1000;
|
||||
else if (slug.startsWith(q)) score = 500;
|
||||
else if (slug.includes(q)) score = 300;
|
||||
else if (tags.includes(q)) score = 200;
|
||||
else if (tags.some(x => x.includes(q))) score = 100;
|
||||
else if (slogan.includes(q)) score = 50;
|
||||
if (score > 0) scored.push({ score, t });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score || a.t.slug.localeCompare(b.t.slug));
|
||||
return scored.slice(0, limit).map(s => s.t);
|
||||
}
|
||||
|
||||
export async function deleteService(
|
||||
uuid: string,
|
||||
opts: {
|
||||
|
||||
Reference in New Issue
Block a user