Add Coolify REST API server with Scalar docs and UI integration

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

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

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

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

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

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

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

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

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

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