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}\`)"`, ` entryPoints:`, ` - https`, ` service: ${routeName}`, ` tls:`, ` certResolver: letsencrypt`, ``, ` ${routeName}-http:`, ` rule: "Host(\`${domain}\`)"`, ` entryPoints:`, ` - http`, ` middlewares:`, ` - redirect-to-https`, ` service: ${routeName}`, ].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' }; } // Insert router before middlewares section, service before end of services section let newYml; const middlewaresSplit = current.split(' middlewares:'); const servicesSplit = current.split(' services:'); if (middlewaresSplit.length === 2 && servicesSplit.length === 2) { // Insert router block before middlewares, service block at end of services newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1]; // Now add service at the end newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n'; } else 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= — 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}\`)"`, ` entryPoints:`, ` - https`, ` service: ${routeName}`, ` tls:`, ` certResolver: letsencrypt`, ``, ` ${routeName}-http:`, ` rule: "Host(\`${appConfig.domain}\`)"`, ` entryPoints:`, ` - http`, ` middlewares:`, ` - redirect-to-https`, ` service: ${routeName}`, ].join('\n'); const serviceBlock = [ ` ${routeName}:`, ` loadBalancer:`, ` servers:`, ` - url: "http://${server.ip}:${appConfig.port}"`, ].join('\n'); // Insert router before middlewares section, service at end let newYml; const middlewaresSplit = currentYml.split(' middlewares:'); const servicesSplit = currentYml.split(' services:'); if (middlewaresSplit.length === 2 && servicesSplit.length === 2) { newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1]; newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n'; } else 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;