From a4480f9217d66ada594d08745c4e5ecb17332f65 Mon Sep 17 00:00:00 2001 From: mawkone Date: Sat, 16 May 2026 11:29:31 -0700 Subject: [PATCH] fix(agent): ensure is_force_https_enabled is applied to docker compose apps when setting custom domains --- vibn-frontend/lib/coolify.ts | 531 +++++++++++++++++++++-------------- 1 file changed, 324 insertions(+), 207 deletions(-) diff --git a/vibn-frontend/lib/coolify.ts b/vibn-frontend/lib/coolify.ts index c581d43c..eac958e5 100644 --- a/vibn-frontend/lib/coolify.ts +++ b/vibn-frontend/lib/coolify.ts @@ -8,30 +8,35 @@ * COOLIFY_DEFAULT_DESTINATION_UUID — Docker destination on that server */ -const COOLIFY_URL = process.env.COOLIFY_URL ?? 'https://coolify.vibnai.com'; -const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? ''; +const COOLIFY_URL = process.env.COOLIFY_URL ?? "https://coolify.vibnai.com"; +const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? ""; const COOLIFY_DEFAULT_SERVER_UUID = - process.env.COOLIFY_DEFAULT_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc'; + process.env.COOLIFY_DEFAULT_SERVER_UUID ?? "jws4g4cgssss4cw48s488woc"; const COOLIFY_DEFAULT_DESTINATION_UUID = - process.env.COOLIFY_DEFAULT_DESTINATION_UUID ?? 'zkogkggkw0wg40gccks80oo0'; + process.env.COOLIFY_DEFAULT_DESTINATION_UUID ?? "zkogkggkw0wg40gccks80oo0"; export interface CoolifyProject { uuid: string; name: string; description?: string; - environments?: Array<{ id: number; uuid: string; name: string; project_id: number }>; + environments?: Array<{ + id: number; + uuid: string; + name: string; + project_id: number; + }>; } /** All database flavors Coolify v4 can provision. */ export type CoolifyDatabaseType = - | 'postgresql' - | 'mysql' - | 'mariadb' - | 'mongodb' - | 'redis' - | 'keydb' - | 'dragonfly' - | 'clickhouse'; + | "postgresql" + | "mysql" + | "mariadb" + | "mongodb" + | "redis" + | "keydb" + | "dragonfly" + | "clickhouse"; export interface CoolifyDatabase { uuid: string; @@ -44,7 +49,11 @@ export interface CoolifyDatabase { public_port?: number; project_uuid?: string; environment_id?: number; - environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; + environment?: { + id?: number; + project_uuid?: string; + project?: { uuid?: string }; + }; } export interface CoolifyApplication { @@ -58,7 +67,11 @@ export interface CoolifyApplication { project_uuid?: string; environment_id?: number; environment_name?: string; - environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; + environment?: { + id?: number; + project_uuid?: string; + project?: { uuid?: string }; + }; build_pack?: string; } @@ -99,12 +112,12 @@ export interface CoolifyEnvVar { * https://coolify.io/docs/api-reference/api/operations/create-env-by-application-uuid */ const COOLIFY_ENV_WRITE_FIELDS = [ - 'key', - 'value', - 'is_preview', - 'is_literal', - 'is_multiline', - 'is_shown_once', + "key", + "value", + "is_preview", + "is_literal", + "is_multiline", + "is_shown_once", ] as const; type CoolifyEnvWritePayload = { @@ -154,7 +167,7 @@ async function coolifyFetch(path: string, options: RequestInit = {}) { const res = await fetch(url, { ...options, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", Authorization: `Bearer ${COOLIFY_API_TOKEN}`, ...(options.headers ?? {}), }, @@ -173,10 +186,13 @@ async function coolifyFetch(path: string, options: RequestInit = {}) { // ────────────────────────────────────────────────── export async function listProjects(): Promise { - return coolifyFetch('/projects'); + return coolifyFetch("/projects"); } -export async function createProject(name: string, description?: string): Promise { +export async function createProject( + name: string, + description?: string, +): Promise { // Coolify validates the description against a narrow allowlist: // letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`. // Anything else (colon, semicolon, brackets, pipe, …) errors out with @@ -190,8 +206,8 @@ export async function createProject(name: string, description?: string): Promise .trim() .slice(0, 255) : undefined; - return coolifyFetch('/projects', { - method: 'POST', + return coolifyFetch("/projects", { + method: "POST", body: JSON.stringify({ name, description: cleaned }), }); } @@ -201,7 +217,7 @@ export async function getProject(uuid: string): Promise { } export async function deleteProject(uuid: string): Promise { - await coolifyFetch(`/projects/${uuid}`, { method: 'DELETE' }); + await coolifyFetch(`/projects/${uuid}`, { method: "DELETE" }); } // ────────────────────────────────────────────────── @@ -209,7 +225,7 @@ export async function deleteProject(uuid: string): Promise { // ────────────────────────────────────────────────── export async function listServers(): Promise { - return coolifyFetch('/servers'); + return coolifyFetch("/servers"); } // ────────────────────────────────────────────────── @@ -217,7 +233,7 @@ export async function listServers(): Promise { // ────────────────────────────────────────────────── export async function listPrivateKeys(): Promise { - return coolifyFetch('/security/keys'); + return coolifyFetch("/security/keys"); } export async function createPrivateKey(opts: { @@ -225,18 +241,18 @@ export async function createPrivateKey(opts: { privateKeyPem: string; description?: string; }): Promise { - return coolifyFetch('/security/keys', { - method: 'POST', + return coolifyFetch("/security/keys", { + method: "POST", body: JSON.stringify({ name: opts.name, private_key: opts.privateKeyPem, - description: opts.description ?? '', + description: opts.description ?? "", }), }); } export async function deletePrivateKey(uuid: string): Promise { - await coolifyFetch(`/security/keys/${uuid}`, { method: 'DELETE' }); + await coolifyFetch(`/security/keys/${uuid}`, { method: "DELETE" }); } // ────────────────────────────────────────────────── @@ -270,12 +286,15 @@ export interface CreateDatabaseOpts extends CoolifyDbCreateContext { * POST /databases/{type} endpoint and packs the right credential * fields for each flavor. */ -export async function createDatabase(opts: CreateDatabaseOpts): Promise<{ uuid: string }> { +export async function createDatabase( + opts: CreateDatabaseOpts, +): Promise<{ uuid: string }> { const { - type, name, + type, + name, projectUuid, serverUuid = COOLIFY_DEFAULT_SERVER_UUID, - environmentName = 'production', + environmentName = "production", destinationUuid = COOLIFY_DEFAULT_DESTINATION_UUID, instantDeploy = true, description, @@ -302,22 +321,25 @@ export async function createDatabase(opts: CreateDatabaseOpts): Promise<{ uuid: }; return coolifyFetch(`/databases/${type}`, { - method: 'POST', + method: "POST", body: JSON.stringify(stripUndefined({ ...common, ...credentials })), }); } export async function listDatabases(): Promise { - return coolifyFetch('/databases'); + return coolifyFetch("/databases"); } export async function getDatabase(uuid: string): Promise { return coolifyFetch(`/databases/${uuid}`); } -export async function updateDatabase(uuid: string, patch: Record): Promise<{ uuid: string }> { +export async function updateDatabase( + uuid: string, + patch: Record, +): Promise<{ uuid: string }> { return coolifyFetch(`/databases/${uuid}`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(stripUndefined(patch)), }); } @@ -329,48 +351,59 @@ export async function deleteDatabase( deleteVolumes?: boolean; dockerCleanup?: boolean; deleteConnectedNetworks?: boolean; - } = {} + } = {}, ): Promise { const q = new URLSearchParams(); - if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); - if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); - if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); - if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + if (opts.deleteConfigurations !== undefined) + q.set("delete_configurations", String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) + q.set("delete_volumes", String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) + q.set("docker_cleanup", String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) + q.set("delete_connected_networks", String(opts.deleteConnectedNetworks)); const qs = q.toString(); - await coolifyFetch(`/databases/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); + await coolifyFetch(`/databases/${uuid}${qs ? "?" + qs : ""}`, { + method: "DELETE", + }); } export async function startDatabase(uuid: string): Promise { - await coolifyFetch(`/databases/${uuid}/start`, { method: 'POST' }); + await coolifyFetch(`/databases/${uuid}/start`, { method: "POST" }); } export async function stopDatabase(uuid: string): Promise { - await coolifyFetch(`/databases/${uuid}/stop`, { method: 'POST' }); + await coolifyFetch(`/databases/${uuid}/stop`, { method: "POST" }); } export async function restartDatabase(uuid: string): Promise { - await coolifyFetch(`/databases/${uuid}/restart`, { method: 'POST' }); + await coolifyFetch(`/databases/${uuid}/restart`, { method: "POST" }); } // ────────────────────────────────────────────────── // Applications // ────────────────────────────────────────────────── -export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose' | 'dockerimage'; +export type CoolifyBuildPack = + | "nixpacks" + | "static" + | "dockerfile" + | "dockercompose" + | "dockerimage"; export interface CreatePrivateDeployKeyAppOpts { projectUuid: string; privateKeyUuid: string; - gitRepository: string; // SSH URL: git@git.vibnai.com:vibn-mark/repo.git + gitRepository: string; // SSH URL: git@git.vibnai.com:vibn-mark/repo.git gitBranch?: string; - portsExposes: string; // "3000" + portsExposes: string; // "3000" serverUuid?: string; environmentName?: string; destinationUuid?: string; buildPack?: CoolifyBuildPack; name?: string; description?: string; - domains?: string; // comma-separated FQDNs + domains?: string; // comma-separated FQDNs isAutoDeployEnabled?: boolean; isForceHttpsEnabled?: boolean; instantDeploy?: boolean; @@ -383,17 +416,17 @@ export interface CreatePrivateDeployKeyAppOpts { } export async function createPrivateDeployKeyApp( - opts: CreatePrivateDeployKeyAppOpts + opts: CreatePrivateDeployKeyAppOpts, ): Promise<{ uuid: string }> { const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, - environment_name: opts.environmentName ?? 'production', + environment_name: opts.environmentName ?? "production", destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, private_key_uuid: opts.privateKeyUuid, git_repository: opts.gitRepository, - git_branch: opts.gitBranch ?? 'main', - build_pack: opts.buildPack ?? 'nixpacks', + git_branch: opts.gitBranch ?? "main", + build_pack: opts.buildPack ?? "nixpacks", ports_exposes: opts.portsExposes, name: opts.name, description: opts.description, @@ -408,15 +441,15 @@ export async function createPrivateDeployKeyApp( dockerfile_location: opts.dockerfileLocation, manual_webhook_secret_gitea: opts.manualWebhookSecretGitea, }); - return coolifyFetch('/applications/private-deploy-key', { - method: 'POST', + return coolifyFetch("/applications/private-deploy-key", { + method: "POST", body: JSON.stringify(body), }); } export interface CreatePublicAppOpts { projectUuid: string; - gitRepository: string; // https URL (can embed basic-auth creds for private repos) + gitRepository: string; // https URL (can embed basic-auth creds for private repos) gitBranch?: string; portsExposes: string; serverUuid?: string; @@ -438,15 +471,17 @@ export interface CreatePublicAppOpts { manualWebhookSecretGitea?: string; } -export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> { +export async function createPublicApp( + opts: CreatePublicAppOpts, +): Promise<{ uuid: string }> { const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, - environment_name: opts.environmentName ?? 'production', + environment_name: opts.environmentName ?? "production", destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, git_repository: opts.gitRepository, - git_branch: opts.gitBranch ?? 'main', - build_pack: opts.buildPack ?? 'nixpacks', + git_branch: opts.gitBranch ?? "main", + build_pack: opts.buildPack ?? "nixpacks", ports_exposes: opts.portsExposes, name: opts.name, description: opts.description, @@ -462,8 +497,8 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid docker_compose_location: opts.dockerComposeLocation, manual_webhook_secret_gitea: opts.manualWebhookSecretGitea, }); - return coolifyFetch('/applications/public', { - method: 'POST', + return coolifyFetch("/applications/public", { + method: "POST", body: JSON.stringify(body), }); } @@ -474,9 +509,9 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid export interface CreateDockerImageAppOpts { projectUuid: string; - image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine" + image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine" name?: string; - portsExposes?: string; // default "80" + portsExposes?: string; // default "80" domains?: string; description?: string; serverUuid?: string; @@ -492,25 +527,25 @@ export async function createDockerImageApp( const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, - environment_name: opts.environmentName ?? 'production', + 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', + 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', + return coolifyFetch("/applications/dockerimage", { + method: "POST", body: JSON.stringify(body), }); } export interface CreateDockerComposeAppOpts { projectUuid: string; - composeRaw: string; // raw docker-compose YAML as a string + composeRaw: string; // raw docker-compose YAML as a string name?: string; description?: string; serverUuid?: string; @@ -541,35 +576,40 @@ export interface CreateDockerComposeAppOpts { */ export async function createDockerComposeApp( opts: CreateDockerComposeAppOpts, -): Promise<{ uuid: string; resourceType: 'service' }> { +): Promise<{ uuid: string; resourceType: "service" }> { const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, - environment_name: opts.environmentName ?? 'production', + environment_name: opts.environmentName ?? "production", destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, name: opts.name, description: opts.description, - docker_compose_raw: Buffer.from(opts.composeRaw, 'utf8').toString('base64'), + docker_compose_raw: Buffer.from(opts.composeRaw, "utf8").toString("base64"), instant_deploy: opts.instantDeploy ?? false, }); - const created = await coolifyFetch('/applications/dockercompose', { - method: 'POST', + const created = (await coolifyFetch("/applications/dockercompose", { + method: "POST", body: JSON.stringify(body), - }) as { uuid: string }; - return { uuid: created.uuid, resourceType: 'service' }; + })) as { uuid: string }; + return { uuid: created.uuid, resourceType: "service" }; } // ── Coolify Services API ───────────────────────────────────────────── export async function startService(uuid: string): Promise { - await coolifyFetch(`/services/${uuid}/start`, { method: 'POST' }); + await coolifyFetch(`/services/${uuid}/start`, { method: "POST" }); } export async function stopService(uuid: string): Promise { - await coolifyFetch(`/services/${uuid}/stop`, { method: 'POST' }); + await coolifyFetch(`/services/${uuid}/stop`, { method: "POST" }); } -export interface ServiceEnvVar { key: string; value: string; is_preview?: boolean; is_literal?: boolean; } +export interface ServiceEnvVar { + key: string; + value: string; + is_preview?: boolean; + is_literal?: boolean; +} export async function listServiceEnvs(uuid: string): Promise { return coolifyFetch(`/services/${uuid}/envs`); @@ -580,7 +620,7 @@ export async function bulkUpsertServiceEnvs( envs: ServiceEnvVar[], ): Promise { await coolifyFetch(`/services/${uuid}/envs/bulk`, { - method: 'PUT', + method: "PUT", body: JSON.stringify({ data: envs }), }); } @@ -595,14 +635,14 @@ export async function upsertServiceEnv( // PATCH to update them. Try POST first, fall back to PATCH. try { await coolifyFetch(`/services/${uuid}/envs`, { - method: 'POST', + method: "POST", body: JSON.stringify(env), }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('already exists')) { + if (msg.includes("already exists")) { await coolifyFetch(`/services/${uuid}/envs`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(env), }); return; @@ -611,16 +651,18 @@ export async function upsertServiceEnv( } } -export async function listAllServices(): Promise>> { - return coolifyFetch('/services') as Promise>>; +export async function listAllServices(): Promise< + Array> +> { + return coolifyFetch("/services") as Promise>>; } export async function updateApplication( uuid: string, - patch: Record + patch: Record, ): Promise<{ uuid: string }> { return coolifyFetch(`/applications/${uuid}`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(stripUndefined(patch)), }); } @@ -644,10 +686,10 @@ export async function setApplicationDomains( * Cal.com). Ignored for non-compose apps. */ composeService?: string; - } = {} + } = {}, ): Promise<{ uuid: string }> { // Coolify validates each entry as a URL, so bare hostnames need a scheme. - const normalized = domains.map(d => { + const normalized = domains.map((d) => { const trimmed = d.trim(); if (/^https?:\/\//i.test(trimmed)) return trimmed; return `https://${trimmed}`; @@ -656,7 +698,7 @@ export async function setApplicationDomains( let buildPack = opts.buildPack; if (!buildPack) { const app = await getApplication(uuid); - buildPack = (app.build_pack ?? 'nixpacks') as string; + buildPack = (app.build_pack ?? "nixpacks") as string; } // ── Compose apps: set per-service domains ────────────────────────── @@ -664,13 +706,16 @@ export async function setApplicationDomains( // (HTTP 422: "Use docker_compose_domains instead"). Domains live per // compose service — we default to the `server` service, which matches // the majority of self-hostable apps (Twenty, Plane, Cal.com, etc.). - if (buildPack === 'dockercompose') { - const service = (opts.composeService ?? 'server').trim(); + if (buildPack === "dockercompose") { + const service = (opts.composeService ?? "server").trim(); // Coolify accepts an array of {name, domain}; ONE entry per service. // Multiple domains → comma-join into the single service's `domain` // field (Coolify splits on comma internally). - const payload = [{ name: service, domain: normalized.join(',') }]; - return updateApplication(uuid, { docker_compose_domains: payload }); + const payload = [{ name: service, domain: normalized.join(",") }]; + return updateApplication(uuid, { + docker_compose_domains: payload, + is_force_https_enabled: true, + }); } // ── Single-container apps: top-level `domains` (maps to fqdn) ────── @@ -681,7 +726,7 @@ export async function setApplicationDomains( // (200 OK but fqdn unchanged). Fix that in Coolify's server config, // not here. Sending `fqdn` directly returns 422. return updateApplication(uuid, { - domains: normalized.join(','), + domains: normalized.join(","), force_domain_override: opts.forceOverride ?? true, is_force_https_enabled: true, }); @@ -694,52 +739,65 @@ export async function deleteApplication( deleteVolumes?: boolean; dockerCleanup?: boolean; deleteConnectedNetworks?: boolean; - } = {} + } = {}, ): Promise { const q = new URLSearchParams(); - if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); - if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); - if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); - if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + if (opts.deleteConfigurations !== undefined) + q.set("delete_configurations", String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) + q.set("delete_volumes", String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) + q.set("docker_cleanup", String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) + q.set("delete_connected_networks", String(opts.deleteConnectedNetworks)); const qs = q.toString(); - await coolifyFetch(`/applications/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); + await coolifyFetch(`/applications/${uuid}${qs ? "?" + qs : ""}`, { + method: "DELETE", + }); } export async function listApplications(): Promise { - return coolifyFetch('/applications'); + return coolifyFetch("/applications"); } -export async function deployApplication(uuid: string, opts: { force?: boolean } = {}): Promise<{ deployment_uuid: string }> { +export async function deployApplication( + uuid: string, + opts: { force?: boolean } = {}, +): Promise<{ deployment_uuid: string }> { // Coolify v4 exposes deploy as POST /deploy?uuid=...&force=... // The older /applications/{uuid}/deploy path is a 404 on current // Coolify releases. const q = new URLSearchParams({ uuid }); - if (opts.force) q.set('force', 'true'); - const res = await coolifyFetch(`/deploy?${q.toString()}`, { method: 'POST' }); + if (opts.force) q.set("force", "true"); + const res = await coolifyFetch(`/deploy?${q.toString()}`, { method: "POST" }); // Response shape: { deployments: [{ deployment_uuid, ... }] } or direct object. const first = Array.isArray(res?.deployments) ? res.deployments[0] : res; return { - deployment_uuid: first?.deployment_uuid ?? first?.uuid ?? '', + deployment_uuid: first?.deployment_uuid ?? first?.uuid ?? "", }; } -export async function getApplication(uuid: string): Promise { +export async function getApplication( + uuid: string, +): Promise { return coolifyFetch(`/applications/${uuid}`); } export async function startApplication(uuid: string): Promise { - await coolifyFetch(`/applications/${uuid}/start`, { method: 'POST' }); + await coolifyFetch(`/applications/${uuid}/start`, { method: "POST" }); } export async function stopApplication(uuid: string): Promise { - await coolifyFetch(`/applications/${uuid}/stop`, { method: 'POST' }); + await coolifyFetch(`/applications/${uuid}/stop`, { method: "POST" }); } export async function restartApplication(uuid: string): Promise { - await coolifyFetch(`/applications/${uuid}/restart`, { method: 'POST' }); + await coolifyFetch(`/applications/${uuid}/restart`, { method: "POST" }); } -export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: string }> { +export async function getDeploymentLogs( + deploymentUuid: string, +): Promise<{ logs: string }> { return coolifyFetch(`/deployments/${deploymentUuid}/logs`); } @@ -753,10 +811,14 @@ export async function getApplicationRuntimeLogsFromApi( uuid: string, lines = 200, ): Promise<{ logs: string }> { - return coolifyFetch(`/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`); + return coolifyFetch( + `/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`, + ); } -export async function listApplicationDeployments(uuid: string): Promise { +export async function listApplicationDeployments( + uuid: string, +): Promise { // Coolify v4 nests this under /deployments/applications/{uuid} // and returns { count, deployments }. Normalize to a flat array. const raw = await coolifyFetch(`/deployments/applications/${uuid}?take=50`); @@ -778,14 +840,16 @@ export async function createMonorepoAppService(opts: { environmentName?: string; }): Promise { const { - projectUuid, appName, gitRepo, - gitBranch = 'main', + projectUuid, + appName, + gitRepo, + gitBranch = "main", domain, serverUuid = COOLIFY_DEFAULT_SERVER_UUID, - environmentName = 'production', + environmentName = "production", } = opts; return coolifyFetch(`/applications`, { - method: 'POST', + method: "POST", body: JSON.stringify({ project_uuid: projectUuid, name: appName, @@ -793,10 +857,10 @@ export async function createMonorepoAppService(opts: { git_branch: gitBranch, server_uuid: serverUuid, environment_name: environmentName, - build_pack: 'nixpacks', + build_pack: "nixpacks", build_command: `pnpm install && turbo run build --filter=${appName}`, start_command: `turbo run start --filter=${appName}`, - ports_exposes: '3000', + ports_exposes: "3000", fqdn: `https://${domain}`, }), }); @@ -806,13 +870,15 @@ export async function createMonorepoAppService(opts: { // Environment variables // ────────────────────────────────────────────────── -export async function listApplicationEnvs(uuid: string): Promise { +export async function listApplicationEnvs( + uuid: string, +): Promise { return coolifyFetch(`/applications/${uuid}/envs`); } export async function upsertApplicationEnv( uuid: string, - env: CoolifyEnvVar & { is_preview?: boolean } + env: CoolifyEnvVar & { is_preview?: boolean }, ): Promise { // Strip any read-only/derived fields (`is_build_time`, `is_buildtime`, // `is_runtime`, `is_shared`, `uuid`) before sending — Coolify returns @@ -821,14 +887,14 @@ export async function upsertApplicationEnv( const payload = toCoolifyEnvWritePayload(env); try { return await coolifyFetch(`/applications/${uuid}/envs`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(payload), }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('404') || msg.includes('405')) { + if (msg.includes("404") || msg.includes("405")) { return coolifyFetch(`/applications/${uuid}/envs`, { - method: 'POST', + method: "POST", body: JSON.stringify(payload), }); } @@ -836,9 +902,12 @@ export async function upsertApplicationEnv( } } -export async function deleteApplicationEnv(uuid: string, key: string): Promise { +export async function deleteApplicationEnv( + uuid: string, + key: string, +): Promise { await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, { - method: 'DELETE', + method: "DELETE", }); } @@ -854,11 +923,15 @@ export interface CoolifyService { service_type?: string; project_uuid?: string; environment_id?: number; - environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; + environment?: { + id?: number; + project_uuid?: string; + project?: { uuid?: string }; + }; } export async function listServices(): Promise { - return coolifyFetch('/services'); + return coolifyFetch("/services"); } export async function getService(uuid: string): Promise { @@ -867,7 +940,7 @@ export async function getService(uuid: string): Promise { export async function createService(opts: { projectUuid: string; - type: string; // e.g. "pocketbase", "authentik", "zitadel" + type: string; // e.g. "pocketbase", "authentik", "zitadel" name: string; description?: string; serverUuid?: string; @@ -878,14 +951,17 @@ export async function createService(opts: { const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, - environment_name: opts.environmentName ?? 'production', + environment_name: opts.environmentName ?? "production", destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, type: opts.type, name: opts.name, description: opts.description, instant_deploy: opts.instantDeploy ?? true, }); - return coolifyFetch('/services', { method: 'POST', body: JSON.stringify(body) }); + return coolifyFetch("/services", { + method: "POST", + body: JSON.stringify(body), + }); } /** @@ -897,7 +973,7 @@ export async function updateService( patch: Record, ): Promise<{ uuid: string }> { return coolifyFetch(`/services/${uuid}`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(stripUndefined(patch)), }); } @@ -922,9 +998,11 @@ export async function setServiceDomains( uuid: string, urls: Array<{ name: string; url: string }>, ): Promise<{ uuid: string }> { - const normalized = urls.map(u => ({ + const normalized = urls.map((u) => ({ name: u.name, - url: /^https?:\/\//i.test(u.url.trim()) ? u.url.trim() : `https://${u.url.trim()}`, + url: /^https?:\/\//i.test(u.url.trim()) + ? u.url.trim() + : `https://${u.url.trim()}`, })); return updateService(uuid, { urls: normalized }); } @@ -966,9 +1044,12 @@ export interface CoolifyServiceTemplate { const TEMPLATES_URL = process.env.COOLIFY_TEMPLATES_URL ?? - 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json'; + "https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json"; -let templatesCache: { fetchedAt: number; data: Record } | null = null; +let templatesCache: { + fetchedAt: number; + data: Record; +} | null = null; const TEMPLATES_TTL_MS = 60 * 60 * 1000; /** @@ -976,31 +1057,44 @@ const TEMPLATES_TTL_MS = 60 * 60 * 1000; * one hour because the upstream JSON is ~1MB and only changes when * Coolify ships new templates. */ -export async function listServiceTemplates(opts: { force?: boolean } = {}): Promise> { +export async function listServiceTemplates( + opts: { force?: boolean } = {}, +): Promise> { const now = Date.now(); - if (!opts.force && templatesCache && now - templatesCache.fetchedAt < TEMPLATES_TTL_MS) { + 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>; + 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>; const data: Record = {}; 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, + 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, // Coolify's catalog stores port as either a number (e.g. 3000) // or a numeric string (e.g. "3000") — handle both. - port: typeof t.port === 'number' - ? t.port - : typeof t.port === 'string' && /^\d+$/.test(t.port.trim()) - ? Number(t.port.trim()) - : undefined, - documentation: typeof t.documentation === 'string' ? t.documentation : undefined, - minversion: typeof t.minversion === 'string' ? t.minversion : undefined, + port: + typeof t.port === "number" + ? t.port + : typeof t.port === "string" && /^\d+$/.test(t.port.trim()) + ? Number(t.port.trim()) + : undefined, + documentation: + typeof t.documentation === "string" ? t.documentation : undefined, + minversion: typeof t.minversion === "string" ? t.minversion : undefined, }; } templatesCache = { fetchedAt: now, data }; @@ -1026,23 +1120,27 @@ export async function searchServiceTemplates( 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; + 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()); + 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 (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); + return scored.slice(0, limit).map((s) => s.t); } export async function deleteService( @@ -1052,15 +1150,21 @@ export async function deleteService( deleteVolumes?: boolean; dockerCleanup?: boolean; deleteConnectedNetworks?: boolean; - } = {} + } = {}, ): Promise { const q = new URLSearchParams(); - if (opts.deleteConfigurations !== undefined) q.set('delete_configurations', String(opts.deleteConfigurations)); - if (opts.deleteVolumes !== undefined) q.set('delete_volumes', String(opts.deleteVolumes)); - if (opts.dockerCleanup !== undefined) q.set('docker_cleanup', String(opts.dockerCleanup)); - if (opts.deleteConnectedNetworks !== undefined) q.set('delete_connected_networks', String(opts.deleteConnectedNetworks)); + if (opts.deleteConfigurations !== undefined) + q.set("delete_configurations", String(opts.deleteConfigurations)); + if (opts.deleteVolumes !== undefined) + q.set("delete_volumes", String(opts.deleteVolumes)); + if (opts.dockerCleanup !== undefined) + q.set("docker_cleanup", String(opts.dockerCleanup)); + if (opts.deleteConnectedNetworks !== undefined) + q.set("delete_connected_networks", String(opts.deleteConnectedNetworks)); const qs = q.toString(); - await coolifyFetch(`/services/${uuid}${qs ? '?' + qs : ''}`, { method: 'DELETE' }); + await coolifyFetch(`/services/${uuid}${qs ? "?" + qs : ""}`, { + method: "DELETE", + }); } // ────────────────────────────────────────────────── @@ -1081,18 +1185,20 @@ export class TenantError extends Error { } function envIdOf( - resource: CoolifyApplication | CoolifyDatabase | CoolifyService + resource: CoolifyApplication | CoolifyDatabase | CoolifyService, ): number | null { return ( - (typeof resource.environment_id === 'number' ? resource.environment_id : null) ?? - (resource.environment && typeof resource.environment.id === 'number' + (typeof resource.environment_id === "number" + ? resource.environment_id + : null) ?? + (resource.environment && typeof resource.environment.id === "number" ? resource.environment.id : null) ); } function explicitProjectUuidOf( - resource: CoolifyApplication | CoolifyDatabase | CoolifyService + resource: CoolifyApplication | CoolifyDatabase | CoolifyService, ): string | null { return ( resource.project_uuid ?? @@ -1107,7 +1213,7 @@ async function projectEnvIds(projectUuid: string): Promise> { const project = await getProject(projectUuid); const ids = new Set(); for (const env of project.environments ?? []) { - if (typeof env.id === 'number') ids.add(env.id); + if (typeof env.id === "number") ids.add(env.id); } return ids; } @@ -1115,53 +1221,53 @@ async function projectEnvIds(projectUuid: string): Promise> { async function ensureResourceInProject( resource: CoolifyApplication | CoolifyDatabase | CoolifyService, resourceKind: string, - expectedProjectUuid: string + expectedProjectUuid: string, ): Promise { const explicit = explicitProjectUuidOf(resource); if (explicit && explicit === expectedProjectUuid) return; if (explicit && explicit !== expectedProjectUuid) { throw new TenantError( - `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}` + `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`, ); } const envId = envIdOf(resource); if (envId == null) { throw new TenantError( - `${resourceKind} ${resource.uuid} has no environment_id; cannot verify project ${expectedProjectUuid}` + `${resourceKind} ${resource.uuid} has no environment_id; cannot verify project ${expectedProjectUuid}`, ); } const envIds = await projectEnvIds(expectedProjectUuid); if (!envIds.has(envId)) { throw new TenantError( - `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}` + `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`, ); } } export async function getApplicationInProject( appUuid: string, - expectedProjectUuid: string + expectedProjectUuid: string, ): Promise { const app = await getApplication(appUuid); - await ensureResourceInProject(app, 'Application', expectedProjectUuid); + await ensureResourceInProject(app, "Application", expectedProjectUuid); return app; } export async function getDatabaseInProject( dbUuid: string, - expectedProjectUuid: string + expectedProjectUuid: string, ): Promise { const db = await getDatabase(dbUuid); - await ensureResourceInProject(db, 'Database', expectedProjectUuid); + await ensureResourceInProject(db, "Database", expectedProjectUuid); return db; } export async function getServiceInProject( serviceUuid: string, - expectedProjectUuid: string + expectedProjectUuid: string, ): Promise { const svc = await getService(serviceUuid); - await ensureResourceInProject(svc, 'Service', expectedProjectUuid); + await ensureResourceInProject(svc, "Service", expectedProjectUuid); return svc; } @@ -1180,7 +1286,9 @@ async function ensureResourceInWorkspaceProjects( ownedProjectUuids: Set, ): Promise { if (ownedProjectUuids.size === 0) { - throw new TenantError(`${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`); + throw new TenantError( + `${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`, + ); } const explicit = explicitProjectUuidOf(resource); if (explicit && ownedProjectUuids.has(explicit)) return; @@ -1201,9 +1309,9 @@ async function ensureResourceInWorkspaceProjects( ); const allowedEnvIds = new Set(); for (const r of projects) { - if (r.status === 'fulfilled') { + if (r.status === "fulfilled") { for (const env of r.value.environments ?? []) { - if (typeof env.id === 'number') allowedEnvIds.add(env.id); + if (typeof env.id === "number") allowedEnvIds.add(env.id); } } } @@ -1219,7 +1327,11 @@ export async function getApplicationInWorkspace( ownedProjectUuids: Set, ): Promise { const app = await getApplication(appUuid); - await ensureResourceInWorkspaceProjects(app, 'Application', ownedProjectUuids); + await ensureResourceInWorkspaceProjects( + app, + "Application", + ownedProjectUuids, + ); return app; } @@ -1228,7 +1340,7 @@ export async function getDatabaseInWorkspace( ownedProjectUuids: Set, ): Promise { const db = await getDatabase(dbUuid); - await ensureResourceInWorkspaceProjects(db, 'Database', ownedProjectUuids); + await ensureResourceInWorkspaceProjects(db, "Database", ownedProjectUuids); return db; } @@ -1237,7 +1349,7 @@ export async function getServiceInWorkspace( ownedProjectUuids: Set, ): Promise { const svc = await getService(serviceUuid); - await ensureResourceInWorkspaceProjects(svc, 'Service', ownedProjectUuids); + await ensureResourceInWorkspaceProjects(svc, "Service", ownedProjectUuids); return svc; } @@ -1265,27 +1377,32 @@ interface CoolifyProjectEnvResources { * surface on every CoolifyDatabase record. Coolify's flattened * per-resource shape doesn't include `type`, so we derive it from * whichever array we pulled the row out of. */ -const DB_ARRAY_KEYS_TO_TYPE: Array<{ key: keyof CoolifyProjectEnvResources; type: string }> = [ - { key: 'postgresqls', type: 'postgresql' }, - { key: 'mysqls', type: 'mysql' }, - { key: 'mariadbs', type: 'mariadb' }, - { key: 'mongodbs', type: 'mongodb' }, - { key: 'redis', type: 'redis' }, - { key: 'keydbs', type: 'keydb' }, - { key: 'dragonflies', type: 'dragonfly' }, - { key: 'clickhouses', type: 'clickhouse' }, +const DB_ARRAY_KEYS_TO_TYPE: Array<{ + key: keyof CoolifyProjectEnvResources; + type: string; +}> = [ + { key: "postgresqls", type: "postgresql" }, + { key: "mysqls", type: "mysql" }, + { key: "mariadbs", type: "mariadb" }, + { key: "mongodbs", type: "mongodb" }, + { key: "redis", type: "redis" }, + { key: "keydbs", type: "keydb" }, + { key: "dragonflies", type: "dragonfly" }, + { key: "clickhouses", type: "clickhouse" }, ]; async function getProjectEnvResources( projectUuid: string, - envName: string + envName: string, ): Promise { - return coolifyFetch(`/projects/${projectUuid}/${encodeURIComponent(envName)}`); + return coolifyFetch( + `/projects/${projectUuid}/${encodeURIComponent(envName)}`, + ); } async function forEachEnv( projectUuid: string, - collect: (envResources: CoolifyProjectEnvResources) => T[] + collect: (envResources: CoolifyProjectEnvResources) => T[], ): Promise { const project = await getProject(projectUuid); const out: T[] = []; @@ -1297,15 +1414,15 @@ async function forEachEnv( } export async function listApplicationsInProject( - projectUuid: string + projectUuid: string, ): Promise { - return forEachEnv(projectUuid, r => r.applications ?? []); + return forEachEnv(projectUuid, (r) => r.applications ?? []); } export async function listDatabasesInProject( - projectUuid: string + projectUuid: string, ): Promise { - return forEachEnv(projectUuid, r => { + return forEachEnv(projectUuid, (r) => { const out: CoolifyDatabase[] = []; for (const { key, type } of DB_ARRAY_KEYS_TO_TYPE) { const arr = r[key]; @@ -1321,14 +1438,14 @@ export async function listDatabasesInProject( } export async function listServicesInProject( - projectUuid: string + projectUuid: string, ): Promise { - return forEachEnv(projectUuid, r => r.services ?? []); + return forEachEnv(projectUuid, (r) => r.services ?? []); } /** @deprecated Use getApplicationInProject / ensureResourceInProject instead. */ export function projectUuidOf( - resource: CoolifyApplication | CoolifyDatabase | CoolifyService + resource: CoolifyApplication | CoolifyDatabase | CoolifyService, ): string | null { return explicitProjectUuidOf(resource); }