From 93d40455d95bce63e7f89df7a62fb215d1203b0d Mon Sep 17 00:00:00 2001 From: Clint Masden Date: Fri, 27 Feb 2026 11:02:17 -0600 Subject: [PATCH] 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 --- api/lib/config.js | 24 + api/lib/coolify-client.js | 28 + api/lib/ssh.js | 45 ++ api/openapi.json | 329 ++++++++++++ api/routes/coolify.js | 502 ++++++++++++++++++ api/server.js | 33 ++ app/renderer/src/app.jsx | 2 + .../components/coolify/coolify-app-card.jsx | 92 ++++ .../coolify/coolify-deploy-dialog.jsx | 133 +++++ .../src/components/coolify/coolify-page.jsx | 131 +++++ .../src/components/layout/sidebar.jsx | 3 +- app/renderer/src/hooks/use-coolify.js | 54 ++ app/renderer/src/lib/api.js | 33 ++ app/renderer/src/lib/query-keys.js | 6 + app/renderer/vite.config.js | 3 + package.json | 13 +- 16 files changed, 1426 insertions(+), 5 deletions(-) create mode 100644 api/lib/config.js create mode 100644 api/lib/coolify-client.js create mode 100644 api/lib/ssh.js create mode 100644 api/openapi.json create mode 100644 api/routes/coolify.js create mode 100644 api/server.js create mode 100644 app/renderer/src/components/coolify/coolify-app-card.jsx create mode 100644 app/renderer/src/components/coolify/coolify-deploy-dialog.jsx create mode 100644 app/renderer/src/components/coolify/coolify-page.jsx create mode 100644 app/renderer/src/hooks/use-coolify.js diff --git a/api/lib/config.js b/api/lib/config.js new file mode 100644 index 0000000..0ecbe25 --- /dev/null +++ b/api/lib/config.js @@ -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(); +} diff --git a/api/lib/coolify-client.js b/api/lib/coolify-client.js new file mode 100644 index 0000000..fa0338f --- /dev/null +++ b/api/lib/coolify-client.js @@ -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(); +} diff --git a/api/lib/ssh.js b/api/lib/ssh.js new file mode 100644 index 0000000..7825cc4 --- /dev/null +++ b/api/lib/ssh.js @@ -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 }); + }); +} diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..a537b91 --- /dev/null +++ b/api/openapi.json @@ -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" } + } + } + } + } +} diff --git a/api/routes/coolify.js b/api/routes/coolify.js new file mode 100644 index 0000000..28e07f1 --- /dev/null +++ b/api/routes/coolify.js @@ -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= — 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; diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..dfc1011 --- /dev/null +++ b/api/server.js @@ -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`); +}); diff --git a/app/renderer/src/app.jsx b/app/renderer/src/app.jsx index 933f6c6..c4470ed 100644 --- a/app/renderer/src/app.jsx +++ b/app/renderer/src/app.jsx @@ -6,6 +6,7 @@ import { AppLayout } from '@/components/layout/app-layout'; import { DashboardPage } from '@/components/dashboard/dashboard-page'; import { ServersPage } from '@/components/servers/servers-page'; import { ProjectsPage } from '@/components/projects/projects-page'; +import { CoolifyPage } from '@/components/coolify/coolify-page'; const queryClient = new QueryClient({ defaultOptions: { @@ -25,6 +26,7 @@ const router = createHashRouter([ { path: 'servers', element: }, { path: 'projects', element: }, { path: 'projects/:projectName', element: }, + { path: 'coolify', element: }, ], }, ]); diff --git a/app/renderer/src/components/coolify/coolify-app-card.jsx b/app/renderer/src/components/coolify/coolify-app-card.jsx new file mode 100644 index 0000000..ede7340 --- /dev/null +++ b/app/renderer/src/components/coolify/coolify-app-card.jsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Rocket, Trash2, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { StatusDot } from '@/components/shared/status-dot'; + +const buildpackColor = { + dockercompose: 'secondary', + nixpacks: 'outline', + dockerfile: 'outline', +}; + +function statusColor(status) { + if (!status) return 'gray'; + const s = status.toLowerCase(); + if (s.includes('running') || s.includes('healthy')) return 'green'; + if (s.includes('stopped') || s.includes('exited')) return 'red'; + if (s.includes('building') || s.includes('starting')) return 'yellow'; + return 'gray'; +} + +export function CoolifyAppCard({ app, onDeploy, onDelete }) { + const [expanded, setExpanded] = useState(false); + + return ( + + +
+ + + {app.name} + + + {app.build_pack} + +
+
+ + + {/* Port + domain */} +
+ {app._host_port && :{app._host_port}} + {app.git_branch && {app.git_branch}} + {app.fqdn && ( + e.stopPropagation()} + > + {app.fqdn.replace(/^https?:\/\//, '')} + + + )} +
+ + {/* Actions */} +
+ + + +
+ + {/* Expandable env vars */} + {expanded && app._envs && ( +
+ {app._envs.length === 0 && No env vars} + {app._envs.map((env) => ( +
+ {env.key} + = + {env.value} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/renderer/src/components/coolify/coolify-deploy-dialog.jsx b/app/renderer/src/components/coolify/coolify-deploy-dialog.jsx new file mode 100644 index 0000000..f5dfc44 --- /dev/null +++ b/app/renderer/src/components/coolify/coolify-deploy-dialog.jsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { CheckCircle2, Circle, Loader2, XCircle, AlertTriangle, SkipForward } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const STEP_LABELS = { + config: 'Read coolify.json', + server: 'Resolve server', + check: 'Check app existence', + validate: 'Validate config', + sync: 'Create / Update app', + env: 'Set HOST_PORT', + route: 'Configure routing', + changelog: 'Write changelog', + deploy: 'Trigger deployment', +}; + +const statusIcon = { + running: , + done: , + error: , + warn: , + skipped: , +}; + +export function CoolifyDeployDialog({ projectPath, appName, onClose }) { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [steps, setSteps] = useState([]); + + const run = async () => { + setLoading(true); + setError(null); + setResult(null); + setSteps([]); + try { + const res = await fetch('/api/coolify/upsert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectPath, appName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + setSteps(data.steps || []); + if (data.success) { + setResult(data); + } else { + setError(data.error || 'Upsert failed'); + setSteps(data.steps || []); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Deploy to Coolify

+

{projectPath}

+
+ +
+ {/* Not started */} + {!loading && !result && !error && steps.length === 0 && ( +
+

+ This will read coolify.json, + create/update the Coolify app, set env vars, configure Traefik routing, and trigger a deploy. +

+ +
+ )} + + {/* Steps */} + {steps.length > 0 && ( +
+ {steps.map((s) => ( +
+ {statusIcon[s.status] || } +
+

{STEP_LABELS[s.step] || s.step}

+ {s.detail && ( +

{s.detail}

+ )} +
+
+ ))} +
+ )} + + {/* Loading spinner */} + {loading && steps.length === 0 && ( +
+ Running upsert pipeline... +
+ )} + + {/* Success */} + {result && ( +
+

Deployment triggered

+

UUID: {result.uuid}

+ {result.domain && ( + + https://{result.domain} + + )} +
+ )} + + {/* Error */} + {error && ( +
+

Error

+

{error}

+
+ )} +
+ +
+ {error && !loading && ( + + )} + +
+
+
+ ); +} diff --git a/app/renderer/src/components/coolify/coolify-page.jsx b/app/renderer/src/components/coolify/coolify-page.jsx new file mode 100644 index 0000000..6f6deab --- /dev/null +++ b/app/renderer/src/components/coolify/coolify-page.jsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { Cloud, RefreshCw, Rocket, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/shared/empty-state'; +import { CoolifyAppCard } from './coolify-app-card'; +import { CoolifyDeployDialog } from './coolify-deploy-dialog'; +import { useCoolifyApps, useCoolifyDeploy, useCoolifyDelete, useCoolifyNextPort } from '@/hooks/use-coolify'; +import { toast } from 'sonner'; + +export function CoolifyPage() { + const { data: apps, isLoading, error, refetch } = useCoolifyApps(); + const { data: portInfo } = useCoolifyNextPort(); + const deployMut = useCoolifyDeploy(); + const deleteMut = useCoolifyDelete(); + + const [deployDialog, setDeployDialog] = useState(null); // { projectPath, appName } + const [deleteConfirm, setDeleteConfirm] = useState(null); // { uuid, name } + + const handleDeploy = (app) => { + deployMut.mutate(app.uuid, { + onSuccess: () => toast.success(`Deploy triggered for ${app.name}`), + onError: (err) => toast.error(`Deploy failed: ${err.message}`), + }); + }; + + const handleDelete = (app) => { + setDeleteConfirm({ uuid: app.uuid, name: app.name }); + }; + + const confirmDelete = () => { + if (!deleteConfirm) return; + deleteMut.mutate(deleteConfirm.uuid, { + onSuccess: () => { + toast.success(`${deleteConfirm.name} deleted`); + setDeleteConfirm(null); + }, + onError: (err) => toast.error(`Delete failed: ${err.message}`), + }); + }; + + return ( +
+ {/* Header */} +
+
+

+ Coolify +

+

+ {apps ? `${apps.length} apps` : 'Loading...'} + {portInfo && Next port: {portInfo.nextPort}} +

+
+
+ + API Docs + + +
+
+ + {/* Error */} + {error && ( +
+

Failed to load apps

+

{error.message}

+

+ Make sure the API server is running: npm run api +

+
+ )} + + {/* Delete confirmation */} + {deleteConfirm && ( +
+

+ Delete {deleteConfirm.name} from Coolify? This cannot be undone. +

+ + +
+ )} + + {/* Apps grid */} + {!error && apps && apps.length === 0 && ( + + )} + + {apps && apps.length > 0 && ( +
+ {apps.map((app) => ( + + ))} +
+ )} + + {/* Deploy dialog */} + {deployDialog && ( + { + setDeployDialog(null); + refetch(); + }} + /> + )} +
+ ); +} diff --git a/app/renderer/src/components/layout/sidebar.jsx b/app/renderer/src/components/layout/sidebar.jsx index b5f39e9..6a93eca 100644 --- a/app/renderer/src/components/layout/sidebar.jsx +++ b/app/renderer/src/components/layout/sidebar.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, Container, Settings } from 'lucide-react'; +import { LayoutDashboard, Container, Settings, Cloud } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Separator } from '@/components/ui/separator'; import { useServers } from '@/hooks/use-servers'; @@ -8,6 +8,7 @@ import { useAppContext } from '@/lib/app-context'; const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/projects', icon: Container, label: 'Projects' }, + { to: '/coolify', icon: Cloud, label: 'Coolify' }, { to: '/servers', icon: Settings, label: 'Settings' }, ]; diff --git a/app/renderer/src/hooks/use-coolify.js b/app/renderer/src/hooks/use-coolify.js new file mode 100644 index 0000000..df1e84f --- /dev/null +++ b/app/renderer/src/hooks/use-coolify.js @@ -0,0 +1,54 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { coolifyApi } from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; + +export function useCoolifyApps() { + return useQuery({ + queryKey: queryKeys.coolify.apps, + queryFn: () => coolifyApi.listApps(), + staleTime: 30_000, + }); +} + +export function useCoolifyServers() { + return useQuery({ + queryKey: queryKeys.coolify.servers, + queryFn: () => coolifyApi.listServers(), + staleTime: 60_000, + }); +} + +export function useCoolifyNextPort() { + return useQuery({ + queryKey: queryKeys.coolify.nextPort, + queryFn: () => coolifyApi.getNextPort(), + staleTime: 30_000, + }); +} + +export function useCoolifyDeploy() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (uuid) => coolifyApi.deploy(uuid), + onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.coolify.apps }), + }); +} + +export function useCoolifyUpsert() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ projectPath, appName }) => coolifyApi.upsert(projectPath, appName), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.coolify.apps }); + qc.invalidateQueries({ queryKey: queryKeys.coolify.nextPort }); + }, + }); +} + +export function useCoolifyDelete() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (uuid) => coolifyApi.deleteApp(uuid), + onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.coolify.apps }), + }); +} diff --git a/app/renderer/src/lib/api.js b/app/renderer/src/lib/api.js index 210420f..6acdde2 100644 --- a/app/renderer/src/lib/api.js +++ b/app/renderer/src/lib/api.js @@ -36,3 +36,36 @@ export const configApi = { export const toolsApi = { openVSCodeDiff: (data) => api.openVSCodeDiff(data), }; + +// ─── Coolify API (HTTP fetch to Express server) ──────────────────── + +const COOLIFY_BASE = '/api/coolify'; + +async function fetchJson(url, options) { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json(); +} + +export const coolifyApi = { + listApps: () => fetchJson(`${COOLIFY_BASE}/apps`), + findApp: (name) => fetchJson(`${COOLIFY_BASE}/apps/find/${encodeURIComponent(name)}`), + createApp: (data) => fetchJson(`${COOLIFY_BASE}/apps`, { method: 'POST', body: JSON.stringify(data) }), + updateApp: (uuid, data) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteApp: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}`, { method: 'DELETE' }), + listEnvs: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/envs`), + setEnv: (uuid, key, value) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/envs`, { method: 'POST', body: JSON.stringify({ key, value }) }), + deploy: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/deploy`, { method: 'POST' }), + listServers: () => fetchJson(`${COOLIFY_BASE}/servers`), + getNextPort: () => fetchJson(`${COOLIFY_BASE}/next-port`), + addRoute: (routeName, domain, port) => fetchJson(`${COOLIFY_BASE}/routes`, { method: 'POST', body: JSON.stringify({ routeName, domain, port }) }), + checkDrift: (projectPath, appName) => fetchJson(`${COOLIFY_BASE}/drift`, { method: 'POST', body: JSON.stringify({ projectPath, appName }) }), + upsert: (projectPath, appName) => fetchJson(`${COOLIFY_BASE}/upsert`, { method: 'POST', body: JSON.stringify({ projectPath, appName }) }), + readConfig: (projectPath) => fetchJson(`${COOLIFY_BASE}/config?path=${encodeURIComponent(projectPath)}`), +}; diff --git a/app/renderer/src/lib/query-keys.js b/app/renderer/src/lib/query-keys.js index 9f3bde1..14bb48e 100644 --- a/app/renderer/src/lib/query-keys.js +++ b/app/renderer/src/lib/query-keys.js @@ -13,4 +13,10 @@ export const queryKeys = { config: { all: ['config'], }, + coolify: { + apps: ['coolify', 'apps'], + servers: ['coolify', 'servers'], + nextPort: ['coolify', 'nextPort'], + drift: (projectPath) => ['coolify', 'drift', projectPath], + }, }; diff --git a/app/renderer/vite.config.js b/app/renderer/vite.config.js index 21f74ee..4ab16f1 100644 --- a/app/renderer/vite.config.js +++ b/app/renderer/vite.config.js @@ -19,5 +19,8 @@ export default defineConfig({ server: { port: 5173, strictPort: true, + proxy: { + '/api': 'http://localhost:3100', + }, }, }); diff --git a/package.json b/package.json index b2f7e48..b230132 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "type": "module", "scripts": { "docker-deploy": "node cli/index.js", + "api": "node api/server.js", + "api:dev": "node --watch api/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ @@ -18,12 +20,15 @@ "author": "", "license": "MIT", "dependencies": { - "commander": "^12.1.0", - "handlebars": "^4.7.8", + "@scalar/express-api-reference": "^0.8.46", "chalk": "^5.3.0", + "commander": "^12.1.0", + "cors": "^2.8.6", + "express": "^5.2.1", + "glob": "^11.0.0", + "handlebars": "^4.7.8", "inquirer": "^11.1.0", - "ssh2": "^1.16.0", - "glob": "^11.0.0" + "ssh2": "^1.16.0" }, "engines": { "node": ">=18.0.0"