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 }; } /** * Insert a Traefik route into custom.yml safely. * Parses the three top-level sections (routers, middlewares, services) by * finding lines that match /^ \w+:$/ (exactly 2-space indent, no deeper). * This avoids the substring-match bug where " middlewares:" inside a * router definition gets confused with the " middlewares:" section header. * * Returns the new YAML string, or throws if structure is unrecognized. */ function insertTraefikRoute(currentYml, routerBlock, serviceBlock) { const lines = currentYml.split('\n'); // Find exact line indices for the 3 top-level sections under http: // They are always indented with exactly 2 spaces: " routers:", " middlewares:", " services:" let routersLine = -1, middlewaresLine = -1, servicesLine = -1; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^ routers:\s*$/.test(line)) routersLine = i; else if (/^ middlewares:\s*$/.test(line)) middlewaresLine = i; else if (/^ services:\s*$/.test(line)) servicesLine = i; } if (routersLine === -1 || servicesLine === -1) { throw new Error('Traefik custom.yml missing required sections (routers/services). Aborting to avoid corruption.'); } // Insert router block: just before middlewares (if exists) or just before services const insertRouterBefore = middlewaresLine !== -1 ? middlewaresLine : servicesLine; // Build new lines array const before = lines.slice(0, insertRouterBefore); // Remove trailing blank lines from the routers section while (before.length > 0 && before[before.length - 1].trim() === '') before.pop(); before.push(''); // single blank line separator before.push(...routerBlock.split('\n')); before.push(''); // blank line before next section const middle = lines.slice(insertRouterBefore, servicesLine); const servicesSection = lines.slice(servicesLine); // Remove trailing blank lines from services while (servicesSection.length > 0 && servicesSection[servicesSection.length - 1].trim() === '') servicesSection.pop(); servicesSection.push(''); // blank line before new service servicesSection.push(...serviceBlock.split('\n')); servicesSection.push(''); // trailing newline const newLines = [...before, ...middle, ...servicesSection]; const result = newLines.join('\n'); // Sanity check: verify the 3 sections still exist if (!result.includes(' routers:') || !result.includes(' services:')) { throw new Error('Traefik YAML validation failed after insertion. Aborting to avoid corruption.'); } return result; } 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 }); } }; } async function giteaFetch(config, path, options = {}) { const giteaUrl = config.gitea?.url || `http://${config.coolify?.sshHost}:2004`; const giteaToken = config.gitea?.token || config.token; const res = await fetch(`${giteaUrl}/api/v1${path}`, { ...options, headers: { 'Authorization': `token ${giteaToken}`, 'Content-Type': 'application/json', ...options.headers, }, }); if (!res.ok) { const text = await res.text(); throw new Error(`Gitea API ${res.status}: ${text}`); } const text = await res.text(); return text ? JSON.parse(text) : null; } // ─── 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; if (!routeName || !domain || !port) { return { error: 'Missing required fields: routeName, domain, port' }; } 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' }; } const newYml = insertTraefikRoute(current, routerBlock, serviceBlock); const b64 = Buffer.from(newYml).toString('base64'); await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`); return { added: true }; })); // DELETE /routes — remove a Traefik route by routeName router.delete('/routes', wrap(async (req) => { const config = loadConfig(); const { routeName } = req.body; if (!routeName) { return { error: 'Missing required field: routeName' }; } const traefikPath = config.coolify?.traefikPath; const current = await sshExec(config, `sudo cat "${traefikPath}"`); if (!current.includes(` ${routeName}:`)) { return { removed: false, reason: 'route_not_found' }; } const lines = current.split('\n'); const filtered = []; let skip = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trimStart(); const indent = line.length - trimmed.length; // Match routeName: or routeName-http: at 4-space indent (router/service level) if (indent === 4 && (trimmed === routeName + ':' || trimmed.startsWith(routeName + ':') || trimmed === routeName + '-http:' || trimmed.startsWith(routeName + '-http:'))) { skip = true; continue; } if (skip) { if (line.trim() === '' || indent > 4) continue; skip = false; } filtered.push(line); } // Clean up double blank lines const cleaned = []; for (let i = 0; i < filtered.length; i++) { if (filtered[i].trim() === '' && i > 0 && cleaned.length > 0 && cleaned[cleaned.length - 1].trim() === '') continue; cleaned.push(filtered[i]); } const newYml = cleaned.join('\n'); // Validate structure if (!newYml.includes(' routers:') || !newYml.includes(' services:')) { throw new Error('Traefik YAML validation failed after removal. Aborting to avoid corruption.'); } // Verify route is gone if (newYml.includes(` ${routeName}:`)) { throw new Error(`Route ${routeName} still found after removal. Aborting.`); } const b64 = Buffer.from(newYml).toString('base64'); await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`); return { removed: true, routeName }; })); // 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 gitOwner = appConfig.gitOwner || config.owner; const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${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 gitOwner = appConfig.gitOwner || config.owner; const gitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${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('Host(`' + 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'); const newYml = insertTraefikRoute(currentYml, routerBlock, serviceBlock); 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}`); // Step 10: Webhook — set Coolify secret + create Gitea webhook const webhookSecret = config.coolify?.webhookSecret; if (webhookSecret) { step('webhook', 'running', 'Configuring auto-deploy webhook...'); const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; const giteaOwner = appConfig.gitOwner || config.owner || 'clint'; try { // Set secret on Coolify app await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }), }); // Create/update Gitea webhook const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); const existing = hooks.find(h => h.config?.url === webhookUrl); if (existing) { await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, { method: 'PATCH', body: JSON.stringify({ config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, active: true, }), }); step('webhook', 'done', `Webhook updated (hook ${existing.id}) → auto-deploy on push`); } else { const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, { method: 'POST', body: JSON.stringify({ type: 'gitea', active: true, config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, events: ['push'], }), }); step('webhook', 'done', `Webhook created (hook ${created.id}) → auto-deploy on push`); } } catch (err) { step('webhook', 'done', `Warning: webhook setup failed — ${err.message}`); } } else { step('webhook', 'skipped', 'No webhookSecret in config — skipping auto-deploy setup'); } return { success: true, uuid: appUuid, domain: appConfig.domain, changelogEntry: entry, steps }; })); // ─── Webhook Management ───────────────────────────────────────── // POST /webhooks/setup — configure webhooks on Coolify apps + Gitea repos // Sets manual_webhook_secret_gitea on each Coolify app, then creates/updates // a Gitea webhook on the corresponding repo pointing to Coolify's internal URL. router.post('/webhooks/setup', wrap(async (req) => { const config = loadConfig(); const { appName } = req.body; // optional — if omitted, sets up ALL apps const webhookSecret = config.coolify?.webhookSecret; if (!webhookSecret) throw new Error('Missing coolify.webhookSecret in config.json'); const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; const giteaOwner = config.owner || 'clint'; const apps = await coolifyFetch(config, '/applications'); const targets = appName ? apps.filter(a => a.name === appName) : apps; const results = []; for (const app of targets) { const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null }; // 1. Set webhook secret on Coolify app try { await coolifyFetch(config, `/applications/${app.uuid}`, { method: 'PATCH', body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }), }); result.coolify = 'secret_set'; } catch (err) { result.coolify = `error: ${err.message}`; } // 2. Create/update webhook on Gitea repo const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); if (!gitRepoMatch) { result.gitea = 'skipped: no git repo configured'; results.push(result); continue; } const repoName = gitRepoMatch[1]; try { const existingHooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); const existing = existingHooks.find(h => h.config?.url === webhookUrl); if (existing) { await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, { method: 'PATCH', body: JSON.stringify({ config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, active: true, }), }); result.gitea = `updated (hook ${existing.id})`; } else { const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, { method: 'POST', body: JSON.stringify({ type: 'gitea', active: true, config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, events: ['push'], }), }); result.gitea = `created (hook ${created.id})`; } } catch (err) { result.gitea = `error: ${err.message}`; } results.push(result); } return { webhookUrl, appsConfigured: results.length, results }; })); // DELETE /webhooks/teardown — remove webhooks from all (or specified) apps router.delete('/webhooks/teardown', wrap(async (req) => { const config = loadConfig(); const { appName } = req.body; const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; const giteaOwner = config.owner || 'clint'; const apps = await coolifyFetch(config, '/applications'); const targets = appName ? apps.filter(a => a.name === appName) : apps; const results = []; for (const app of targets) { const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null }; try { await coolifyFetch(config, `/applications/${app.uuid}`, { method: 'PATCH', body: JSON.stringify({ manual_webhook_secret_gitea: '' }), }); result.coolify = 'secret_cleared'; } catch (err) { result.coolify = `error: ${err.message}`; } const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); if (!gitRepoMatch) { result.gitea = 'skipped'; results.push(result); continue; } const repoName = gitRepoMatch[1]; try { const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); const matching = hooks.filter(h => h.config?.url === webhookUrl); for (const hook of matching) { await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${hook.id}`, { method: 'DELETE' }); } result.gitea = matching.length > 0 ? `removed ${matching.length} hook(s)` : 'none found'; } catch (err) { result.gitea = `error: ${err.message}`; } results.push(result); } return { appsConfigured: results.length, results }; })); // GET /webhooks/status — check webhook status across all apps router.get('/webhooks/status', wrap(async () => { const config = loadConfig(); const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; const giteaOwner = config.owner || 'clint'; const apps = await coolifyFetch(config, '/applications'); const results = []; for (const app of apps) { const result = { app: app.name, uuid: app.uuid, coolifySecretSet: false, giteaWebhookActive: false, repo: null }; try { const detail = await coolifyFetch(config, `/applications/${app.uuid}`); result.coolifySecretSet = !!detail.manual_webhook_secret_gitea; } catch { /* skip */ } const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); if (gitRepoMatch) { result.repo = gitRepoMatch[1]; try { const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${result.repo}/hooks`); result.giteaWebhookActive = hooks.some(h => h.config?.url === webhookUrl && h.active); } catch { /* skip */ } } results.push(result); } return { webhookUrl, results }; })); export default router;