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:
@@ -49,6 +49,7 @@ import {
|
|||||||
listAllServices,
|
listAllServices,
|
||||||
listServiceEnvs,
|
listServiceEnvs,
|
||||||
upsertServiceEnv,
|
upsertServiceEnv,
|
||||||
|
setServiceDomains,
|
||||||
updateApplication,
|
updateApplication,
|
||||||
deleteApplication,
|
deleteApplication,
|
||||||
setApplicationDomains,
|
setApplicationDomains,
|
||||||
@@ -61,6 +62,8 @@ import {
|
|||||||
createService,
|
createService,
|
||||||
getServiceInProject,
|
getServiceInProject,
|
||||||
deleteService,
|
deleteService,
|
||||||
|
listServiceTemplates,
|
||||||
|
searchServiceTemplates,
|
||||||
type CoolifyDatabaseType,
|
type CoolifyDatabaseType,
|
||||||
} from '@/lib/coolify';
|
} from '@/lib/coolify';
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
@@ -82,7 +85,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.3.0',
|
version: '2.4.0',
|
||||||
authentication: {
|
authentication: {
|
||||||
scheme: 'Bearer',
|
scheme: 'Bearer',
|
||||||
tokenPrefix: 'vibn_sk_',
|
tokenPrefix: 'vibn_sk_',
|
||||||
@@ -112,6 +115,8 @@ export async function GET() {
|
|||||||
'apps.exec',
|
'apps.exec',
|
||||||
'apps.volumes.list',
|
'apps.volumes.list',
|
||||||
'apps.volumes.wipe',
|
'apps.volumes.wipe',
|
||||||
|
'apps.templates.list',
|
||||||
|
'apps.templates.search',
|
||||||
'apps.envs.list',
|
'apps.envs.list',
|
||||||
'apps.envs.upsert',
|
'apps.envs.upsert',
|
||||||
'apps.envs.delete',
|
'apps.envs.delete',
|
||||||
@@ -212,6 +217,10 @@ export async function POST(request: Request) {
|
|||||||
return await toolAppsVolumesList(principal, params);
|
return await toolAppsVolumesList(principal, params);
|
||||||
case 'apps.volumes.wipe':
|
case 'apps.volumes.wipe':
|
||||||
return await toolAppsVolumesWipe(principal, params);
|
return await toolAppsVolumesWipe(principal, params);
|
||||||
|
case 'apps.templates.list':
|
||||||
|
return await toolAppsTemplatesList(params);
|
||||||
|
case 'apps.templates.search':
|
||||||
|
return await toolAppsTemplatesSearch(params);
|
||||||
|
|
||||||
case 'databases.list':
|
case 'databases.list':
|
||||||
return await toolDatabasesList(principal);
|
return await toolDatabasesList(principal);
|
||||||
@@ -746,9 +755,9 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* apps.create — three distinct pathways depending on what you pass:
|
* apps.create — four distinct pathways depending on what you pass:
|
||||||
*
|
*
|
||||||
* 1. Gitea repo (existing behaviour — for user-owned custom apps)
|
* 1. Gitea repo (for user-owned custom apps)
|
||||||
* Required: repo
|
* Required: repo
|
||||||
* Optional: branch, buildPack, ports, domain, envs, …
|
* Optional: branch, buildPack, ports, domain, envs, …
|
||||||
*
|
*
|
||||||
@@ -760,12 +769,17 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
|
|||||||
* Required: composeRaw (the full docker-compose.yml contents as a string)
|
* Required: composeRaw (the full docker-compose.yml contents as a string)
|
||||||
* Optional: name, domain, composeDomains, envs
|
* Optional: name, domain, composeDomains, envs
|
||||||
*
|
*
|
||||||
* Pathways 2 and 3 do NOT create a Gitea repo. They deploy directly
|
* 4. **Coolify one-click template** (RECOMMENDED for popular apps)
|
||||||
* from Docker Hub / any public registry, or from the raw YAML you
|
* Required: template e.g. "twenty", "n8n", "supabase", "ghost"
|
||||||
* supply. Use these for third-party apps (Twenty, Directus, Cal.com…).
|
* 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.
|
||||||
*
|
*
|
||||||
* Use pathway 1 for user's own code that lives in the workspace's
|
* Pathway 1 is for code in the workspace's Gitea org. Pathways 2/3/4
|
||||||
* Gitea org.
|
* deploy third-party apps without creating a Gitea repo.
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
@@ -785,6 +799,100 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
instantDeploy: false,
|
instantDeploy: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coolify auto-assigns sslip.io URLs. Replace them with the
|
||||||
|
// user's FQDN. We rebuild the urls array by reading the service
|
||||||
|
// back to learn the docker-compose service names (template-specific).
|
||||||
|
let urlsApplied = false;
|
||||||
|
try {
|
||||||
|
// Brief settle so the service is fully committed
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
const svc = await getService(created.uuid) as Record<string, unknown>;
|
||||||
|
// Coolify stores per-service urls under different shapes across versions:
|
||||||
|
// - service.fqdn : "https://x.sslip.io,https://y.sslip.io"
|
||||||
|
// - service.urls : [{ name, url }]
|
||||||
|
// For simplicity, target the docker-compose service named after
|
||||||
|
// the template slug (covers ~90% of templates: twenty, n8n, ghost,
|
||||||
|
// wordpress, etc). Users can adjust later via apps.domains.set.
|
||||||
|
await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]);
|
||||||
|
urlsApplied = true;
|
||||||
|
void svc; // reserved for future heuristic
|
||||||
|
} 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;
|
||||||
|
if (params.instantDeploy !== false) {
|
||||||
|
try {
|
||||||
|
await startService(created.uuid);
|
||||||
|
started = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[mcp apps.create/template] service start failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
result: {
|
||||||
|
uuid: created.uuid,
|
||||||
|
name: appName,
|
||||||
|
domain: fqdn,
|
||||||
|
url: `https://${fqdn}`,
|
||||||
|
resourceType: 'service',
|
||||||
|
template: templateSlug,
|
||||||
|
urlsApplied,
|
||||||
|
started,
|
||||||
|
note: started
|
||||||
|
? 'Service start was queued. First boot may take 1-5 min while Coolify pulls images and runs migrations. Use apps.logs to monitor.'
|
||||||
|
: 'Service created but not yet started. Call apps.deploy to start it.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pathway 2: Docker image ───────────────────────────────────────────
|
// ── Pathway 2: Docker image ───────────────────────────────────────────
|
||||||
if (params.image) {
|
if (params.image) {
|
||||||
const image = String(params.image).trim();
|
const image = String(params.image).trim();
|
||||||
@@ -864,7 +972,7 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
|
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
|
||||||
if (!ws.gitea_org) {
|
if (!ws.gitea_org) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `image` or `composeRaw` instead of `repo`.' },
|
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `template` (recommended), `image`, or `composeRaw` instead of `repo`.' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -928,6 +1036,76 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// 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 } });
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
|
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
|
||||||
function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse {
|
function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse {
|
||||||
const fqdn = String(domainParam ?? '').trim()
|
const fqdn = String(domainParam ?? '').trim()
|
||||||
|
|||||||
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) });
|
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(
|
export async function deleteService(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
opts: {
|
opts: {
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ const nextConfig: NextConfig = {
|
|||||||
root: turbopackRoot,
|
root: turbopackRoot,
|
||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["@prisma/client", "prisma"],
|
// ssh2 ships native .node binaries; turbopack can't bundle them
|
||||||
|
// ("non-ecmascript placeable asset"). Externalize so they're loaded
|
||||||
|
// at runtime via Node's require, the same way @prisma/client works.
|
||||||
|
serverExternalPackages: ["@prisma/client", "prisma", "ssh2", "cpu-features"],
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user