Add Coolify REST API server with Scalar docs and UI integration

Express API server on :3100 exposing all Coolify operations:
- CRUD for apps, env vars, servers
- Full upsert pipeline (create/update + env + route + deploy)
- Drift detection, Traefik route management via SSH
- Scalar API docs at /reference, OpenAPI 3.1 spec

UI: New Coolify page with app cards, deploy/delete actions,
env var expansion. Sidebar nav + React Query hooks + fetch client.

Both UI and LLM/CLI use the same HTTP endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 11:02:17 -06:00
parent 2fe49b6725
commit 93d40455d9
16 changed files with 1426 additions and 5 deletions

24
api/lib/config.js Normal file
View File

@@ -0,0 +1,24 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Shared config from gitea.repo.management
const CONFIG_PATH = join(__dirname, '..', '..', '..', 'gitea.repo.management', 'config.json');
let _cached = null;
export function loadConfig() {
if (_cached) return _cached;
if (!existsSync(CONFIG_PATH)) {
throw new Error(`Config not found at ${CONFIG_PATH}`);
}
_cached = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
return _cached;
}
export function reloadConfig() {
_cached = null;
return loadConfig();
}

28
api/lib/coolify-client.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* Authenticated fetch wrapper for the Coolify API.
* Ported from gitea.repo.management/electron/main.cjs:166-183
*/
export async function coolifyFetch(config, apiPath, options = {}) {
const base = (config.coolify?.apiUrl || '').replace(/\/$/, '');
const token = config.coolify?.apiToken || '';
const url = `${base}/api/v1${apiPath}`;
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...(options.headers || {})
};
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const text = await res.text();
const err = new Error(`Coolify ${options.method || 'GET'} ${apiPath} failed: ${res.status} ${text}`);
err.status = res.status;
throw err;
}
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return res.json();
return res.text();
}

45
api/lib/ssh.js Normal file
View File

@@ -0,0 +1,45 @@
import { Client } from 'ssh2';
/**
* Execute a command on the remote server via SSH.
* Uses the ssh2 library (already a project dependency) instead of
* the plink/sshpass fallback chain from gitea.repo.management.
*/
export function sshExec(config, command) {
const host = config.coolify?.sshHost;
const user = config.coolify?.sshUser;
const password = config.coolify?.sshPassword;
if (!host || !user) {
return Promise.reject(new Error('SSH not configured — set coolify.sshHost and coolify.sshUser in config.json'));
}
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) { conn.end(); return reject(err); }
let stdout = '';
let stderr = '';
stream.on('close', (code) => {
conn.end();
if (code !== 0 && stderr) {
reject(new Error(stderr.trim()));
} else {
resolve(stdout.trim());
}
});
stream.on('data', (data) => { stdout += data.toString(); });
stream.stderr.on('data', (data) => { stderr += data.toString(); });
});
});
conn.on('error', (err) => reject(err));
conn.connect({ host, port: 22, username: user, password });
});
}

329
api/openapi.json Normal file
View File

@@ -0,0 +1,329 @@
{
"openapi": "3.1.0",
"info": {
"title": "Coolify Deployment API",
"description": "REST API for managing Coolify applications, Traefik routing, and automated deployments. Used by both the desktop UI and LLM agents.",
"version": "1.0.0"
},
"servers": [
{ "url": "http://localhost:3100", "description": "Local dev" }
],
"paths": {
"/api/coolify/apps": {
"get": {
"operationId": "listApps",
"summary": "List all Coolify apps",
"description": "Returns all applications from Coolify, enriched with HOST_PORT env vars and env list.",
"tags": ["Apps"],
"responses": {
"200": {
"description": "Array of Coolify applications",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/App" } } } }
}
}
},
"post": {
"operationId": "createApp",
"summary": "Create a new Coolify application",
"tags": ["Apps"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateAppRequest" }
}
}
},
"responses": {
"200": { "description": "Created app object" }
}
}
},
"/api/coolify/apps/find/{name}": {
"get": {
"operationId": "findApp",
"summary": "Find app by name",
"tags": ["Apps"],
"parameters": [
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "App object or null" }
}
}
},
"/api/coolify/apps/{uuid}": {
"patch": {
"operationId": "updateApp",
"summary": "Update an existing app",
"tags": ["Apps"],
"parameters": [
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "type": "object" } } }
},
"responses": {
"200": { "description": "Updated app" }
}
},
"delete": {
"operationId": "deleteApp",
"summary": "Delete an app from Coolify",
"tags": ["Apps"],
"parameters": [
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Deletion confirmation" }
}
}
},
"/api/coolify/apps/{uuid}/envs": {
"get": {
"operationId": "listEnvs",
"summary": "List env vars for an app",
"tags": ["Environment"],
"parameters": [
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Array of env vars" }
}
},
"post": {
"operationId": "setEnv",
"summary": "Set an environment variable",
"tags": ["Environment"],
"parameters": [
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"key": { "type": "string" },
"value": { "type": "string" }
},
"required": ["key", "value"]
}
}
}
},
"responses": {
"200": { "description": "Env var created/updated" }
}
}
},
"/api/coolify/apps/{uuid}/deploy": {
"post": {
"operationId": "deployApp",
"summary": "Trigger deployment for an app",
"tags": ["Deploy"],
"parameters": [
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Deployment triggered" }
}
}
},
"/api/coolify/servers": {
"get": {
"operationId": "listServers",
"summary": "List all Coolify servers",
"tags": ["Servers"],
"responses": {
"200": { "description": "Array of servers" }
}
}
},
"/api/coolify/next-port": {
"get": {
"operationId": "getNextPort",
"summary": "Get next available HOST_PORT",
"description": "Scans Coolify env vars and Traefik config to find the highest used port, returns max+1.",
"tags": ["Infrastructure"],
"responses": {
"200": {
"description": "Next port info",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"nextPort": { "type": "integer" },
"usedPorts": { "type": "array", "items": { "type": "integer" } }
}
}
}
}
}
}
}
},
"/api/coolify/routes": {
"post": {
"operationId": "addRoute",
"summary": "Add a Traefik route via SSH",
"tags": ["Infrastructure"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"routeName": { "type": "string", "description": "Traefik service/router name (alphanumeric)" },
"domain": { "type": "string", "description": "Domain name (e.g. timer.dotrepo.com)" },
"port": { "type": "integer", "description": "HOST_PORT on target server" }
},
"required": ["routeName", "domain", "port"]
}
}
}
},
"responses": {
"200": { "description": "Route added or already exists" }
}
}
},
"/api/coolify/drift": {
"post": {
"operationId": "checkDrift",
"summary": "Check drift between coolify.json and live Coolify state",
"tags": ["Deploy"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectPath": { "type": "string", "description": "Absolute path to project directory" },
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
},
"required": ["projectPath"]
}
}
}
},
"responses": {
"200": { "description": "Drift check result with diffs" }
}
}
},
"/api/coolify/upsert": {
"post": {
"operationId": "upsertApp",
"summary": "Full deploy pipeline: read config → create/update → env → route → deploy",
"description": "Reads coolify.json from the project, creates or updates the Coolify app, sets HOST_PORT, configures Traefik routing, writes changelog, and triggers deployment. This is the primary endpoint for deploying apps.",
"tags": ["Deploy"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"projectPath": { "type": "string", "description": "Absolute path to project directory containing coolify.json" },
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
},
"required": ["projectPath"]
}
}
}
},
"responses": {
"200": {
"description": "Upsert result with steps and changelog",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UpsertResult" }
}
}
}
}
}
},
"/api/coolify/config": {
"get": {
"operationId": "readCoolifyConfig",
"summary": "Read coolify.json from a project directory",
"tags": ["Config"],
"parameters": [
{ "name": "path", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Absolute path to project directory" }
],
"responses": {
"200": { "description": "Parsed coolify.json contents" }
}
}
},
"/api/health": {
"get": {
"operationId": "healthCheck",
"summary": "Health check",
"tags": ["System"],
"responses": {
"200": { "description": "Server status" }
}
}
}
},
"components": {
"schemas": {
"App": {
"type": "object",
"properties": {
"uuid": { "type": "string" },
"name": { "type": "string" },
"build_pack": { "type": "string", "enum": ["dockercompose", "nixpacks", "dockerfile"] },
"git_repository": { "type": "string" },
"git_branch": { "type": "string" },
"status": { "type": "string" },
"fqdn": { "type": "string" },
"_host_port": { "type": "string", "nullable": true },
"_envs": { "type": "array" }
}
},
"CreateAppRequest": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "App name in Coolify" },
"buildpack": { "type": "string", "enum": ["dockercompose", "nixpacks"], "default": "dockercompose" },
"gitRepo": { "type": "string", "description": "Git SSH URL" },
"gitBranch": { "type": "string", "default": "master" },
"isStatic": { "type": "boolean", "default": false },
"publishDir": { "type": "string", "default": "dist" },
"portsExposes": { "type": "string", "default": "3000" },
"baseDirectory": { "type": "string", "default": "/" },
"dockerComposeLocation": { "type": "string", "default": "/docker-compose.yml" },
"serverUuid": { "type": "string", "description": "Override server UUID" }
},
"required": ["name", "gitRepo"]
},
"UpsertResult": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"uuid": { "type": "string" },
"domain": { "type": "string" },
"changelogEntry": { "type": "object" },
"steps": { "type": "array", "items": { "$ref": "#/components/schemas/Step" } }
}
},
"Step": {
"type": "object",
"properties": {
"step": { "type": "string" },
"status": { "type": "string", "enum": ["running", "done", "error", "skipped", "warn"] },
"detail": { "type": "string" }
}
}
}
}
}

502
api/routes/coolify.js Normal file
View File

@@ -0,0 +1,502 @@
import { Router } from 'express';
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join, basename } from 'path';
import { loadConfig } from '../lib/config.js';
import { coolifyFetch } from '../lib/coolify-client.js';
import { sshExec } from '../lib/ssh.js';
const router = Router();
// ─── Helpers ───────────────────────────────────────────────────────
function readCoolifyJson(projectPath) {
const cfgPath = join(projectPath, 'coolify.json');
if (!existsSync(cfgPath)) return null;
const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
return Array.isArray(parsed) ? parsed : [parsed];
}
async function resolveServer(config, serverName) {
if (!serverName) {
return { uuid: config.coolify?.serverUuid, ip: config.coolify?.targetIp || '192.168.69.5', isLocal: false };
}
const servers = await coolifyFetch(config, '/servers');
const match = servers.find(s => s.name === serverName);
if (!match) throw new Error(`Server "${serverName}" not found in Coolify. Available: ${servers.map(s => s.name).join(', ')}`);
const isLocal = match.ip === 'host.docker.internal' || match.name === 'localhost';
const ip = isLocal ? (config.coolify?.sshHost || '192.168.69.4') : match.ip;
return { uuid: match.uuid, ip, isLocal, name: match.name };
}
function wrap(fn) {
return async (req, res) => {
try {
const result = await fn(req, res);
if (!res.headersSent) res.json(result);
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
};
}
// ─── Routes ────────────────────────────────────────────────────────
// GET /apps — list all Coolify apps (enriched with HOST_PORT)
router.get('/apps', wrap(async () => {
const config = loadConfig();
const apps = await coolifyFetch(config, '/applications');
const enriched = await Promise.all(apps.map(async (app) => {
try {
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
const hostPort = envs.find(e => e.key === 'HOST_PORT');
return { ...app, _host_port: hostPort ? hostPort.value : null, _envs: envs };
} catch {
return { ...app, _host_port: null, _envs: [] };
}
}));
return enriched;
}));
// GET /apps/find/:name — find app by name
router.get('/apps/find/:name', wrap(async (req) => {
const config = loadConfig();
const apps = await coolifyFetch(config, '/applications');
return apps.find(a => a.name === req.params.name) || null;
}));
// POST /apps — create a new Coolify application
router.post('/apps', wrap(async (req) => {
const config = loadConfig();
const { name, buildpack, gitRepo, gitBranch, isStatic, publishDir, portsExposes, baseDirectory, dockerComposeLocation, serverUuid } = req.body;
const body = {
project_uuid: config.coolify?.projectUuid,
server_uuid: serverUuid || config.coolify?.serverUuid,
environment_name: 'production',
name,
git_repository: gitRepo,
git_branch: gitBranch || 'master',
build_pack: buildpack || 'nixpacks',
ports_exposes: portsExposes || '3000',
instant_deploy: false,
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w',
base_directory: baseDirectory || '/',
};
if (buildpack === 'dockercompose') {
body.docker_compose_location = dockerComposeLocation || '/docker-compose.yml';
}
if (isStatic) {
body.is_static = true;
body.publish_directory = publishDir || 'dist';
}
return coolifyFetch(config, '/applications/private-deploy-key', {
method: 'POST',
body: JSON.stringify(body)
});
}));
// PATCH /apps/:uuid — update an existing app
router.patch('/apps/:uuid', wrap(async (req) => {
const config = loadConfig();
return coolifyFetch(config, `/applications/${req.params.uuid}`, {
method: 'PATCH',
body: JSON.stringify(req.body)
});
}));
// DELETE /apps/:uuid — delete an app
router.delete('/apps/:uuid', wrap(async (req) => {
const config = loadConfig();
return coolifyFetch(config, `/applications/${req.params.uuid}`, { method: 'DELETE' });
}));
// GET /apps/:uuid/envs — list env vars for an app
router.get('/apps/:uuid/envs', wrap(async (req) => {
const config = loadConfig();
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`);
}));
// POST /apps/:uuid/envs — set an env var
router.post('/apps/:uuid/envs', wrap(async (req) => {
const config = loadConfig();
const { key, value } = req.body;
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`, {
method: 'POST',
body: JSON.stringify({ key, value, is_preview: false })
});
}));
// POST /apps/:uuid/deploy — trigger deployment
router.post('/apps/:uuid/deploy', wrap(async (req) => {
const config = loadConfig();
return coolifyFetch(config, `/deploy?uuid=${req.params.uuid}&force=true`);
}));
// GET /servers — list all Coolify servers
router.get('/servers', wrap(async () => {
const config = loadConfig();
return coolifyFetch(config, '/servers');
}));
// GET /next-port — get next available HOST_PORT
router.get('/next-port', wrap(async () => {
const config = loadConfig();
const ports = [];
try {
const apps = await coolifyFetch(config, '/applications');
for (const app of apps) {
try {
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
const hp = envs.find(e => e.key === 'HOST_PORT');
if (hp && hp.value) ports.push(parseInt(hp.value, 10));
} catch { /* skip */ }
}
} catch { /* fallback */ }
try {
const traefikPath = config.coolify?.traefikPath;
const raw = await sshExec(config, `sudo cat "${traefikPath}"`);
const portMatches = raw.match(/http:\/\/192\.168\.69\.\d+:(\d+)/g) || [];
for (const m of portMatches) {
const p = parseInt(m.match(/:(\d+)$/)[1], 10);
if (!ports.includes(p)) ports.push(p);
}
} catch { /* SSH may not be configured */ }
const maxPort = ports.length > 0 ? Math.max(...ports) : 2006;
return { nextPort: maxPort + 1, usedPorts: ports.sort((a, b) => a - b) };
}));
// POST /routes — add a Traefik route via SSH
router.post('/routes', wrap(async (req) => {
const config = loadConfig();
const { routeName, domain, port } = req.body;
const traefikPath = config.coolify?.traefikPath;
const targetIp = config.coolify?.targetIp || '192.168.69.5';
const current = await sshExec(config, `sudo cat "${traefikPath}"`);
const routerBlock = [
` ${routeName}:`,
` rule: "Host(\`${domain}\`)"`,
` service: ${routeName}`,
` entryPoints: [websecure]`,
` tls:`,
` certResolver: letsencrypt`
].join('\n');
const serviceBlock = [
` ${routeName}:`,
` loadBalancer:`,
` servers:`,
` - url: "http://${targetIp}:${port}"`
].join('\n');
if (current.includes(`${routeName}:`) && current.includes(`${targetIp}:${port}`)) {
return { added: false, reason: 'route_exists' };
}
let newYml;
const servicesSplit = current.split(' services:');
if (servicesSplit.length === 2) {
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
} else {
newYml = current.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
}
const b64 = Buffer.from(newYml).toString('base64');
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
return { added: true };
}));
// POST /drift — check drift between coolify.json and live Coolify state
router.post('/drift', wrap(async (req) => {
const config = loadConfig();
const { projectPath, appName } = req.body;
const configs = readCoolifyJson(projectPath);
if (!configs || configs.length === 0) return { exists: false, diffs: [], configs: [] };
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
if (!appConfig) return { exists: false, diffs: [], configs };
const apps = await coolifyFetch(config, '/applications');
const app = apps.find(a => a.name === appConfig.name);
if (!app) return { exists: false, diffs: [], appConfig, configs };
const diffs = [];
const compare = (field, localVal, remoteVal, label) => {
if (String(localVal || '') !== String(remoteVal || '')) {
diffs.push({ field, label, local: localVal, remote: remoteVal });
}
};
compare('buildpack', appConfig.buildpack, app.build_pack, 'Buildpack');
compare('branch', appConfig.branch || 'master', app.git_branch, 'Branch');
compare('baseDirectory', appConfig.baseDirectory || '/', app.base_directory, 'Base directory');
if (appConfig.buildpack === 'dockercompose') {
compare('dockerComposeLocation', appConfig.dockerComposeLocation || '/docker-compose.yml', app.docker_compose_location, 'Compose file');
}
if (appConfig.buildpack === 'nixpacks') {
compare('static', appConfig.static ? 'true' : 'false', app.is_static ? 'true' : 'false', 'Static site');
if (appConfig.static) {
compare('publishDir', appConfig.publishDir || 'dist', app.publish_directory, 'Publish directory');
}
}
const repoName = appConfig.repo || basename(projectPath);
const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
compare('repo', expectedGitRepo, app.git_repository, 'Git repository');
try {
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
const hostPort = envs.find(e => e.key === 'HOST_PORT');
if (hostPort) {
compare('port', String(appConfig.port), hostPort.value, 'HOST_PORT');
} else if (appConfig.port) {
diffs.push({ field: 'port', label: 'HOST_PORT', local: String(appConfig.port), remote: '(not set)' });
}
} catch { /* envs may not be available */ }
if (appConfig.domain && app.fqdn) {
const expectedFqdn = `https://${appConfig.domain}`;
compare('domain', expectedFqdn, app.fqdn, 'Domain (FQDN)');
}
return { exists: true, app: { uuid: app.uuid, name: app.name }, diffs, appConfig, configs };
}));
// GET /config?path=<projectPath> — read coolify.json from a project directory
router.get('/config', wrap(async (req) => {
const projectPath = req.query.path;
if (!projectPath) throw Object.assign(new Error('Missing "path" query parameter'), { status: 400 });
return readCoolifyJson(projectPath) || { error: 'No coolify.json found' };
}));
// POST /upsert — full pipeline: read config → find/create app → env → route → deploy
router.post('/upsert', wrap(async (req) => {
const config = loadConfig();
const { projectPath, appName } = req.body;
const steps = [];
const changelogChanges = [];
const step = (name, status, detail) => {
const idx = steps.findIndex(s => s.step === name);
const entry = { step: name, status, detail };
if (idx >= 0) steps[idx] = entry; else steps.push(entry);
};
// Step 1: Read coolify.json
step('config', 'running', 'Reading coolify.json...');
const configs = readCoolifyJson(projectPath);
if (!configs || configs.length === 0) throw new Error('No coolify.json found in project');
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
const repoName = appConfig.repo || basename(projectPath);
const gitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
const branch = appConfig.branch || 'master';
const baseDir = appConfig.baseDirectory || '/';
const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml';
step('config', 'done', `name=${appConfig.name} buildpack=${appConfig.buildpack} port=${appConfig.port}`);
// Step 2: Resolve server
step('server', 'running', `Resolving server "${appConfig.server || 'default'}"...`);
const server = await resolveServer(config, appConfig.server);
step('server', 'done', `uuid=${server.uuid} ip=${server.ip} local=${server.isLocal}`);
// Step 3: Check if app exists
step('check', 'running', 'Searching Coolify applications...');
const apps = await coolifyFetch(config, '/applications');
let existing = apps.find(a => a.name === appConfig.name);
if (existing) {
const detail = await coolifyFetch(config, `/applications/${existing.uuid}`);
existing = detail;
step('check', 'done', `FOUND — will UPDATE uuid=${existing.uuid}`);
} else {
step('check', 'done', `NOT FOUND — will CREATE`);
}
// Step 4: Validate
step('validate', 'running', 'Comparing config...');
if (existing) {
const check = (field, localVal, remoteVal, label) => {
if (String(localVal ?? '') !== String(remoteVal ?? '')) {
changelogChanges.push({ field, label, old: remoteVal, new: localVal });
}
};
check('build_pack', appConfig.buildpack, existing.build_pack, 'Buildpack');
check('git_repository', gitRepo, existing.git_repository, 'Git repository');
check('git_branch', branch, existing.git_branch, 'Branch');
check('base_directory', baseDir, existing.base_directory, 'Base directory');
if (appConfig.buildpack === 'dockercompose') {
check('docker_compose_location', composeLocation, existing.docker_compose_location, 'Compose file');
}
if (appConfig.buildpack === 'nixpacks') {
check('is_static', appConfig.static || false, existing.is_static || false, 'Static site');
if (appConfig.static) check('publish_directory', appConfig.publishDir || 'dist', existing.publish_directory, 'Publish dir');
}
}
step('validate', 'done', changelogChanges.length > 0 ? `${changelogChanges.length} field(s) will change` : existing ? 'No changes needed' : 'New app');
// Step 5: Create or Update
let appUuid;
if (existing) {
step('sync', 'running', `Updating ${existing.uuid}...`);
const updates = {
build_pack: appConfig.buildpack,
git_repository: gitRepo,
git_branch: branch,
base_directory: baseDir,
is_static: appConfig.static || false,
publish_directory: appConfig.publishDir || (appConfig.static ? 'dist' : '/'),
ports_exposes: '3000'
};
if (appConfig.buildpack === 'dockercompose') updates.docker_compose_location = composeLocation;
await coolifyFetch(config, `/applications/${existing.uuid}`, { method: 'PATCH', body: JSON.stringify(updates) });
appUuid = existing.uuid;
step('sync', 'done', `UPDATED ${appUuid}`);
} else {
step('sync', 'running', 'Creating new app...');
const body = {
project_uuid: config.coolify?.projectUuid,
server_uuid: server.uuid,
environment_name: 'production',
name: appConfig.name,
git_repository: gitRepo,
git_branch: branch,
build_pack: appConfig.buildpack || 'nixpacks',
base_directory: baseDir,
ports_exposes: '3000',
instant_deploy: false,
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w'
};
if (appConfig.buildpack === 'dockercompose') body.docker_compose_location = composeLocation;
const created = await coolifyFetch(config, '/applications/private-deploy-key', { method: 'POST', body: JSON.stringify(body) });
appUuid = created.uuid;
changelogChanges.push({ field: '_action', label: 'Action', old: null, new: 'CREATED' });
// Post-create PATCH
const postPatch = {};
if (appConfig.static) {
postPatch.is_static = true;
postPatch.publish_directory = appConfig.publishDir || '/dist';
}
if (appConfig.buildpack !== 'dockercompose') postPatch.domains = '';
if (Object.keys(postPatch).length > 0) {
await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify(postPatch) });
}
step('sync', 'done', `CREATED ${appUuid}`);
}
// Step 6: Set HOST_PORT env var
step('env', 'running', `Setting HOST_PORT=${appConfig.port}...`);
let existingEnvs = [];
try { existingEnvs = await coolifyFetch(config, `/applications/${appUuid}/envs`); } catch { /* ok */ }
const existingHostPort = existingEnvs.find(e => e.key === 'HOST_PORT');
if (existingHostPort && existingHostPort.value === String(appConfig.port)) {
step('env', 'done', `HOST_PORT=${appConfig.port} (unchanged)`);
} else {
if (existingHostPort) {
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: existingHostPort.value, new: String(appConfig.port) });
try {
await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, {
method: 'PATCH', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
});
} catch {
try { await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, { method: 'DELETE' }); } catch { /* ok */ }
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
});
}
} else {
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: null, new: String(appConfig.port) });
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
});
}
step('env', 'done', `HOST_PORT=${appConfig.port} (set)`);
}
// Step 7: Routing
if (!appConfig.domain) {
step('route', 'skipped', 'No domain configured');
} else if (server.isLocal) {
await coolifyFetch(config, `/applications/${appUuid}`, {
method: 'PATCH', body: JSON.stringify({ fqdn: `https://${appConfig.domain}` })
});
step('route', 'done', `FQDN set: https://${appConfig.domain}`);
} else {
if (appConfig.buildpack !== 'dockercompose') {
try { await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify({ domains: '' }) }); } catch { /* ok */ }
}
const routeName = appConfig.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
const traefikPath = config.coolify?.traefikPath;
const currentYml = await sshExec(config, `sudo cat "${traefikPath}"`);
if (currentYml.includes(appConfig.domain)) {
step('route', 'done', `Traefik route already exists for ${appConfig.domain}`);
} else {
const routerBlock = [
` ${routeName}:`,
` rule: "Host(\`${appConfig.domain}\`)"`,
` service: ${routeName}`,
` entryPoints: [websecure]`,
` tls:`,
` certResolver: letsencrypt`,
].join('\n');
const serviceBlock = [
` ${routeName}:`,
` loadBalancer:`,
` servers:`,
` - url: "http://${server.ip}:${appConfig.port}"`,
].join('\n');
let newYml;
const servicesSplit = currentYml.split(' services:');
if (servicesSplit.length === 2) {
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
} else {
newYml = currentYml.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
}
const b64 = Buffer.from(newYml).toString('base64');
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
changelogChanges.push({ field: 'traefik_route', label: 'Traefik route', old: null, new: `${appConfig.domain}${server.ip}:${appConfig.port}` });
step('route', 'done', `Route added: ${appConfig.domain}${server.ip}:${appConfig.port}`);
}
}
// Step 8: Changelog
step('changelog', 'running', 'Writing changelog...');
const changelogPath = join(projectPath, 'coolify.changelog.json');
let changelog = [];
if (existsSync(changelogPath)) {
try { changelog = JSON.parse(readFileSync(changelogPath, 'utf8')); } catch { changelog = []; }
}
const entry = {
timestamp: new Date().toISOString(),
action: existing ? 'update' : 'create',
appName: appConfig.name,
uuid: appUuid,
changes: changelogChanges,
configSnapshot: { ...appConfig },
};
changelog.push(entry);
writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf8');
step('changelog', 'done', `${changelogChanges.length} change(s) logged`);
// Step 9: Deploy
step('deploy', 'running', 'Triggering deployment...');
const deployResult = await coolifyFetch(config, `/deploy?uuid=${appUuid}&force=true`);
step('deploy', 'done', `Deployment triggered — ${appConfig.domain ? `https://${appConfig.domain}` : appUuid}`);
return { success: true, uuid: appUuid, domain: appConfig.domain, changelogEntry: entry, steps };
}));
export default router;

33
api/server.js Normal file
View File

@@ -0,0 +1,33 @@
import express from 'express';
import cors from 'cors';
import { apiReference } from '@scalar/express-api-reference';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import coolifyRoutes from './routes/coolify.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf8'));
const app = express();
const PORT = process.env.PORT || 3100;
app.use(cors());
app.use(express.json());
// API routes
app.use('/api/coolify', coolifyRoutes);
// OpenAPI spec
app.get('/openapi.json', (req, res) => res.json(spec));
// Scalar API docs
app.use('/reference', apiReference({ spec: { url: '/openapi.json' } }));
// Health check
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
app.listen(PORT, () => {
console.log(`API server running on http://localhost:${PORT}`);
console.log(`Scalar docs at http://localhost:${PORT}/reference`);
});