Add full REST API for all deployment operations (projects, servers, docker)
Port all IPC handlers to HTTP endpoints so the UI and LLM use the same API. Adds routes/projects.js (scan, compare, init), routes/servers.js (CRUD, containers, logs), routes/docker.js (build, deploy, pull, vscode-diff). Enhanced ssh.js with full SSHService class (SFTP upload/download). Updated renderer api.js to use fetch instead of window.api IPC. Added concurrently for npm run dev (API + Vite + Electron). OpenAPI spec now covers all 24 endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,81 @@
|
|||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PROJECT_ROOT = join(__dirname, '..', '..');
|
||||||
|
|
||||||
// Shared config from gitea.repo.management
|
// Shared Coolify config from gitea.repo.management
|
||||||
const CONFIG_PATH = join(__dirname, '..', '..', '..', 'gitea.repo.management', 'config.json');
|
const COOLIFY_CONFIG_PATH = join(PROJECT_ROOT, '..', 'gitea.repo.management', 'config.json');
|
||||||
|
|
||||||
let _cached = null;
|
// Local deployment config
|
||||||
|
const DEPLOY_CONFIG_PATH = join(PROJECT_ROOT, 'deployment-config.json');
|
||||||
|
|
||||||
|
// SSH credentials from app/.env
|
||||||
|
const ENV_PATH = join(PROJECT_ROOT, 'app', '.env');
|
||||||
|
|
||||||
|
let _coolifyCache = null;
|
||||||
|
let _deployCache = null;
|
||||||
|
|
||||||
export function loadConfig() {
|
export function loadConfig() {
|
||||||
if (_cached) return _cached;
|
if (_coolifyCache) return _coolifyCache;
|
||||||
if (!existsSync(CONFIG_PATH)) {
|
if (!existsSync(COOLIFY_CONFIG_PATH)) {
|
||||||
throw new Error(`Config not found at ${CONFIG_PATH}`);
|
throw new Error(`Coolify config not found at ${COOLIFY_CONFIG_PATH}`);
|
||||||
}
|
}
|
||||||
_cached = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
_coolifyCache = JSON.parse(readFileSync(COOLIFY_CONFIG_PATH, 'utf8'));
|
||||||
return _cached;
|
return _coolifyCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reloadConfig() {
|
export function reloadConfig() {
|
||||||
_cached = null;
|
_coolifyCache = null;
|
||||||
return loadConfig();
|
return loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadDeployConfig() {
|
||||||
|
if (_deployCache) return _deployCache;
|
||||||
|
if (!existsSync(DEPLOY_CONFIG_PATH)) {
|
||||||
|
_deployCache = { servers: [], projectsRoot: 'C:\\.bucket\\repos.gitea', projects: {} };
|
||||||
|
return _deployCache;
|
||||||
|
}
|
||||||
|
_deployCache = JSON.parse(readFileSync(DEPLOY_CONFIG_PATH, 'utf8'));
|
||||||
|
return _deployCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDeployConfig(config) {
|
||||||
|
writeFileSync(DEPLOY_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||||
|
_deployCache = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadDeployConfig() {
|
||||||
|
_deployCache = null;
|
||||||
|
return loadDeployConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEnv() {
|
||||||
|
if (!existsSync(ENV_PATH)) return {};
|
||||||
|
const content = readFileSync(ENV_PATH, 'utf8');
|
||||||
|
const env = {};
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
if (line.startsWith('#') || !line.includes('=')) continue;
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
env[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectsRoot() {
|
||||||
|
return loadDeployConfig().projectsRoot || 'C:\\.bucket\\repos.gitea';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerSshConfig(server) {
|
||||||
|
const env = loadEnv();
|
||||||
|
return {
|
||||||
|
host: server.host,
|
||||||
|
port: 22,
|
||||||
|
username: env.SSH_USERNAME || server.username,
|
||||||
|
password: env.SSH_PASSWORD || server.password,
|
||||||
|
useSudo: server.useSudo || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
153
api/lib/ssh.js
153
api/lib/ssh.js
@@ -1,9 +1,155 @@
|
|||||||
import { Client } from 'ssh2';
|
import { Client } from 'ssh2';
|
||||||
|
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a command on the remote server via SSH.
|
* SSHService — wraps ssh2 Client with exec + SFTP operations.
|
||||||
* Uses the ssh2 library (already a project dependency) instead of
|
* Ported from app/main/ssh-service.js to ESM for the API server.
|
||||||
* the plink/sshpass fallback chain from gitea.repo.management.
|
*/
|
||||||
|
export class SSHService {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client = new Client();
|
||||||
|
|
||||||
|
this.client.on('ready', () => resolve());
|
||||||
|
this.client.on('error', (err) => reject(err));
|
||||||
|
|
||||||
|
this.client.connect({
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || 22,
|
||||||
|
username: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client) return reject(new Error('Not connected'));
|
||||||
|
|
||||||
|
this.client.exec(command, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
if (code !== 0 && stderr) {
|
||||||
|
reject(new Error(stderr.trim()));
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('data', (data) => { stdout += data.toString(); });
|
||||||
|
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadFile(localPath, remotePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client) return reject(new Error('Not connected'));
|
||||||
|
|
||||||
|
this.client.sftp((err, sftp) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const readStream = createReadStream(localPath);
|
||||||
|
const writeStream = sftp.createWriteStream(remotePath);
|
||||||
|
|
||||||
|
writeStream.on('close', () => resolve());
|
||||||
|
writeStream.on('error', (e) => reject(e));
|
||||||
|
readStream.on('error', (e) => reject(e));
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(remotePath, localPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client) return reject(new Error('Not connected'));
|
||||||
|
|
||||||
|
this.client.sftp((err, sftp) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const localDir = dirname(localPath);
|
||||||
|
if (!existsSync(localDir)) mkdirSync(localDir, { recursive: true });
|
||||||
|
|
||||||
|
const readStream = sftp.createReadStream(remotePath);
|
||||||
|
const writeStream = createWriteStream(localPath);
|
||||||
|
|
||||||
|
writeStream.on('close', () => resolve());
|
||||||
|
writeStream.on('error', (e) => reject(e));
|
||||||
|
readStream.on('error', (e) => reject(e));
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readRemoteFile(remotePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client) return reject(new Error('Not connected'));
|
||||||
|
|
||||||
|
this.client.sftp((err, sftp) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
sftp.readFile(remotePath, 'utf-8', (e, data) => {
|
||||||
|
if (e) reject(e); else resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listDirectory(remotePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client) return reject(new Error('Not connected'));
|
||||||
|
|
||||||
|
this.client.sftp((err, sftp) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
sftp.readdir(remotePath, (e, list) => {
|
||||||
|
if (e) reject(e);
|
||||||
|
else resolve(list.map(item => ({
|
||||||
|
name: item.filename,
|
||||||
|
isDirectory: item.attrs.isDirectory(),
|
||||||
|
size: item.attrs.size,
|
||||||
|
mtime: item.attrs.mtime,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a directory recursively via SFTP.
|
||||||
|
*/
|
||||||
|
async uploadDirectory(localDir, remoteDir) {
|
||||||
|
await this.exec(`mkdir -p ${remoteDir}`);
|
||||||
|
const entries = readdirSync(localDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const localPath = join(localDir, entry.name);
|
||||||
|
const remotePath = `${remoteDir}/${entry.name}`;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.uploadDirectory(localPath, remotePath);
|
||||||
|
} else {
|
||||||
|
await this.uploadFile(localPath, remotePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot SSH exec (for Coolify operations that don't need persistent connection).
|
||||||
*/
|
*/
|
||||||
export function sshExec(config, command) {
|
export function sshExec(config, command) {
|
||||||
const host = config.coolify?.sshHost;
|
const host = config.coolify?.sshHost;
|
||||||
@@ -39,7 +185,6 @@ export function sshExec(config, command) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
conn.on('error', (err) => reject(err));
|
conn.on('error', (err) => reject(err));
|
||||||
|
|
||||||
conn.connect({ host, port: 22, username: user, password });
|
conn.connect({ host, port: 22, username: user, password });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
481
api/openapi.json
481
api/openapi.json
@@ -1,39 +1,312 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Coolify Deployment API",
|
"title": "Docker Deployment & Coolify API",
|
||||||
"description": "REST API for managing Coolify applications, Traefik routing, and automated deployments. Used by both the desktop UI and LLM agents.",
|
"description": "REST API for managing Docker deployments, Coolify applications, Traefik routing, and server management. Used by both the desktop UI and LLM agents.",
|
||||||
"version": "1.0.0"
|
"version": "2.0.0"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{ "url": "http://localhost:3100", "description": "Local dev" }
|
{ "url": "http://localhost:3100", "description": "Local dev" }
|
||||||
],
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "Projects", "description": "Local project scanning and comparison" },
|
||||||
|
{ "name": "Servers", "description": "Deployment server CRUD and remote scanning" },
|
||||||
|
{ "name": "Docker", "description": "Build, deploy, pull files" },
|
||||||
|
{ "name": "Coolify Apps", "description": "Coolify application management" },
|
||||||
|
{ "name": "Coolify Environment", "description": "App environment variables" },
|
||||||
|
{ "name": "Coolify Deploy", "description": "Deployment pipelines and drift checks" },
|
||||||
|
{ "name": "Coolify Infrastructure", "description": "Traefik routes and port management" },
|
||||||
|
{ "name": "System", "description": "Health checks and config" }
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/coolify/apps": {
|
"/api/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listApps",
|
"operationId": "scanLocalProjects",
|
||||||
"summary": "List all Coolify apps",
|
"summary": "Scan local projects",
|
||||||
"description": "Returns all applications from Coolify, enriched with HOST_PORT env vars and env list.",
|
"description": "Scans the configured projectsRoot directory for projects with Dockerfiles, docker-compose files, coolify.json, etc.",
|
||||||
"tags": ["Apps"],
|
"tags": ["Projects"],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Array of Coolify applications",
|
"description": "Array of local project info",
|
||||||
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/App" } } } }
|
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LocalProject" } } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/compare": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "createApp",
|
"operationId": "compareProject",
|
||||||
"summary": "Create a new Coolify application",
|
"summary": "Compare local vs deployed files",
|
||||||
"tags": ["Apps"],
|
"description": "Compares docker-compose.yml, .env, data/, and additional configured files between local and remote.",
|
||||||
|
"tags": ["Projects"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": { "$ref": "#/components/schemas/CreateAppRequest" }
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectPath": { "type": "string", "description": "Absolute local path to the project" },
|
||||||
|
"serverId": { "type": "string", "description": "Server ID to compare against" },
|
||||||
|
"remotePath": { "type": "string", "description": "Remote path (e.g. ~/containers/myapp)" }
|
||||||
|
},
|
||||||
|
"required": ["projectPath", "serverId", "remotePath"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Comparison result with file-by-file diff statuses" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/init": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "initProject",
|
||||||
|
"summary": "Initialize a project with deployment config",
|
||||||
|
"tags": ["Projects"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectPath": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["projectPath"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Init result" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/servers": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listServers",
|
||||||
|
"summary": "List deployment servers",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of configured servers",
|
||||||
|
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Server" } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "saveServer",
|
||||||
|
"summary": "Add or update a server",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Server" } } }
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Saved server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/servers/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteServer",
|
||||||
|
"summary": "Delete a server",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Deletion confirmation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/servers/{id}/scan": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "scanServer",
|
||||||
|
"summary": "Scan remote server for deployed containers",
|
||||||
|
"description": "Lists ~/containers on the remote server and inspects each for docker-compose.yml, .env, data/, etc.",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Array of deployed projects on the server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/servers/{id}/containers": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getRunningContainers",
|
||||||
|
"summary": "List running Docker containers on server",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of running containers",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "containers": { "type": "array", "items": { "$ref": "#/components/schemas/Container" } } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/servers/{id}/logs": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getContainerLogs",
|
||||||
|
"summary": "Get container logs from server",
|
||||||
|
"tags": ["Servers"],
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "containerName", "in": "query", "schema": { "type": "string" } },
|
||||||
|
{ "name": "remotePath", "in": "query", "schema": { "type": "string" } },
|
||||||
|
{ "name": "lines", "in": "query", "schema": { "type": "integer", "default": 100 } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Container logs" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/docker/build": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "buildTar",
|
||||||
|
"summary": "Build Docker image tar for a project",
|
||||||
|
"description": "Runs the project's build-image-tar.ps1 script to create a .tar image file.",
|
||||||
|
"tags": ["Docker"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectPath": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["projectPath"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Build result with output" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/docker/deploy": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "deployProject",
|
||||||
|
"summary": "Deploy project to server via SSH",
|
||||||
|
"description": "Uploads tar + compose + env files, loads Docker image, runs docker compose up. Includes health check polling.",
|
||||||
|
"tags": ["Docker"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectPath": { "type": "string", "description": "Local project path" },
|
||||||
|
"serverId": { "type": "string", "description": "Target server ID" },
|
||||||
|
"remotePath": { "type": "string", "description": "Remote deploy path (e.g. ~/containers/myapp)" }
|
||||||
|
},
|
||||||
|
"required": ["projectPath", "serverId", "remotePath"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Deploy result with health status",
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResult" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/docker/pull": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "pullFiles",
|
||||||
|
"summary": "Pull files from remote server",
|
||||||
|
"description": "Downloads files/directories from the remote server to local paths via SFTP.",
|
||||||
|
"tags": ["Docker"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"serverId": { "type": "string" },
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"remotePath": { "type": "string" },
|
||||||
|
"localPath": { "type": "string" },
|
||||||
|
"type": { "type": "string", "enum": ["file", "directory"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["serverId", "files"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Pull result with pulled/errors arrays" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/docker/vscode-diff": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "openVSCodeDiff",
|
||||||
|
"summary": "Open VS Code diff between local and remote file",
|
||||||
|
"tags": ["Docker"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"serverId": { "type": "string" },
|
||||||
|
"localPath": { "type": "string" },
|
||||||
|
"remoteFilePath": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["serverId", "localPath", "remoteFilePath"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Success or error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/coolify/apps": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listCoolifyApps",
|
||||||
|
"summary": "List all Coolify apps",
|
||||||
|
"description": "Returns all applications from Coolify, enriched with HOST_PORT env vars.",
|
||||||
|
"tags": ["Coolify Apps"],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of Coolify applications",
|
||||||
|
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CoolifyApp" } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createCoolifyApp",
|
||||||
|
"summary": "Create a new Coolify application",
|
||||||
|
"tags": ["Coolify Apps"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateCoolifyAppRequest" } } }
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Created app object" }
|
"200": { "description": "Created app object" }
|
||||||
}
|
}
|
||||||
@@ -41,9 +314,9 @@
|
|||||||
},
|
},
|
||||||
"/api/coolify/apps/find/{name}": {
|
"/api/coolify/apps/find/{name}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "findApp",
|
"operationId": "findCoolifyApp",
|
||||||
"summary": "Find app by name",
|
"summary": "Find Coolify app by name",
|
||||||
"tags": ["Apps"],
|
"tags": ["Coolify Apps"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
@@ -54,9 +327,9 @@
|
|||||||
},
|
},
|
||||||
"/api/coolify/apps/{uuid}": {
|
"/api/coolify/apps/{uuid}": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "updateApp",
|
"operationId": "updateCoolifyApp",
|
||||||
"summary": "Update an existing app",
|
"summary": "Update an existing Coolify app",
|
||||||
"tags": ["Apps"],
|
"tags": ["Coolify Apps"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
@@ -64,38 +337,32 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"content": { "application/json": { "schema": { "type": "object" } } }
|
"content": { "application/json": { "schema": { "type": "object" } } }
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": { "200": { "description": "Updated app" } }
|
||||||
"200": { "description": "Updated app" }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "deleteApp",
|
"operationId": "deleteCoolifyApp",
|
||||||
"summary": "Delete an app from Coolify",
|
"summary": "Delete a Coolify app",
|
||||||
"tags": ["Apps"],
|
"tags": ["Coolify Apps"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Deletion confirmation" } }
|
||||||
"200": { "description": "Deletion confirmation" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/apps/{uuid}/envs": {
|
"/api/coolify/apps/{uuid}/envs": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listEnvs",
|
"operationId": "listCoolifyEnvs",
|
||||||
"summary": "List env vars for an app",
|
"summary": "List env vars for a Coolify app",
|
||||||
"tags": ["Environment"],
|
"tags": ["Coolify Environment"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Array of env vars" } }
|
||||||
"200": { "description": "Array of env vars" }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "setEnv",
|
"operationId": "setCoolifyEnv",
|
||||||
"summary": "Set an environment variable",
|
"summary": "Set an environment variable",
|
||||||
"tags": ["Environment"],
|
"tags": ["Coolify Environment"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
@@ -114,40 +381,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": { "200": { "description": "Env var created/updated" } }
|
||||||
"200": { "description": "Env var created/updated" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/apps/{uuid}/deploy": {
|
"/api/coolify/apps/{uuid}/deploy": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "deployApp",
|
"operationId": "deployCoolifyApp",
|
||||||
"summary": "Trigger deployment for an app",
|
"summary": "Trigger Coolify deployment",
|
||||||
"tags": ["Deploy"],
|
"tags": ["Coolify Deploy"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Deployment triggered" } }
|
||||||
"200": { "description": "Deployment triggered" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/servers": {
|
"/api/coolify/servers": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listServers",
|
"operationId": "listCoolifyServers",
|
||||||
"summary": "List all Coolify servers",
|
"summary": "List all Coolify servers",
|
||||||
"tags": ["Servers"],
|
"tags": ["Coolify Infrastructure"],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Array of Coolify servers" } }
|
||||||
"200": { "description": "Array of servers" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/next-port": {
|
"/api/coolify/next-port": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getNextPort",
|
"operationId": "getNextPort",
|
||||||
"summary": "Get next available HOST_PORT",
|
"summary": "Get next available HOST_PORT",
|
||||||
"description": "Scans Coolify env vars and Traefik config to find the highest used port, returns max+1.",
|
"description": "Scans Coolify env vars and Traefik config to find the highest used port.",
|
||||||
"tags": ["Infrastructure"],
|
"tags": ["Coolify Infrastructure"],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Next port info",
|
"description": "Next port info",
|
||||||
@@ -168,9 +429,9 @@
|
|||||||
},
|
},
|
||||||
"/api/coolify/routes": {
|
"/api/coolify/routes": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "addRoute",
|
"operationId": "addTraefikRoute",
|
||||||
"summary": "Add a Traefik route via SSH",
|
"summary": "Add a Traefik route via SSH",
|
||||||
"tags": ["Infrastructure"],
|
"tags": ["Coolify Infrastructure"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -178,25 +439,23 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"routeName": { "type": "string", "description": "Traefik service/router name (alphanumeric)" },
|
"routeName": { "type": "string" },
|
||||||
"domain": { "type": "string", "description": "Domain name (e.g. timer.dotrepo.com)" },
|
"domain": { "type": "string" },
|
||||||
"port": { "type": "integer", "description": "HOST_PORT on target server" }
|
"port": { "type": "integer" }
|
||||||
},
|
},
|
||||||
"required": ["routeName", "domain", "port"]
|
"required": ["routeName", "domain", "port"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": { "200": { "description": "Route added or already exists" } }
|
||||||
"200": { "description": "Route added or already exists" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/drift": {
|
"/api/coolify/drift": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "checkDrift",
|
"operationId": "checkDrift",
|
||||||
"summary": "Check drift between coolify.json and live Coolify state",
|
"summary": "Check drift between coolify.json and live state",
|
||||||
"tags": ["Deploy"],
|
"tags": ["Coolify Deploy"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -204,25 +463,23 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"projectPath": { "type": "string", "description": "Absolute path to project directory" },
|
"projectPath": { "type": "string" },
|
||||||
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
|
"appName": { "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["projectPath"]
|
"required": ["projectPath"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": { "200": { "description": "Drift check result" } }
|
||||||
"200": { "description": "Drift check result with diffs" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/coolify/upsert": {
|
"/api/coolify/upsert": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "upsertApp",
|
"operationId": "upsertCoolifyApp",
|
||||||
"summary": "Full deploy pipeline: read config → create/update → env → route → deploy",
|
"summary": "Full deploy pipeline: 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.",
|
"description": "Reads coolify.json, creates or updates the Coolify app, sets HOST_PORT, configures Traefik, writes changelog, and triggers deployment.",
|
||||||
"tags": ["Deploy"],
|
"tags": ["Coolify Deploy"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -230,8 +487,8 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"projectPath": { "type": "string", "description": "Absolute path to project directory containing coolify.json" },
|
"projectPath": { "type": "string" },
|
||||||
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
|
"appName": { "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["projectPath"]
|
"required": ["projectPath"]
|
||||||
}
|
}
|
||||||
@@ -240,12 +497,8 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Upsert result with steps and changelog",
|
"description": "Upsert result with steps",
|
||||||
"content": {
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpsertResult" } } }
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/UpsertResult" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,14 +506,12 @@
|
|||||||
"/api/coolify/config": {
|
"/api/coolify/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "readCoolifyConfig",
|
"operationId": "readCoolifyConfig",
|
||||||
"summary": "Read coolify.json from a project directory",
|
"summary": "Read coolify.json from a project",
|
||||||
"tags": ["Config"],
|
"tags": ["System"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "path", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Absolute path to project directory" }
|
{ "name": "path", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Parsed coolify.json" } }
|
||||||
"200": { "description": "Parsed coolify.json contents" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/health": {
|
"/api/health": {
|
||||||
@@ -268,15 +519,55 @@
|
|||||||
"operationId": "healthCheck",
|
"operationId": "healthCheck",
|
||||||
"summary": "Health check",
|
"summary": "Health check",
|
||||||
"tags": ["System"],
|
"tags": ["System"],
|
||||||
"responses": {
|
"responses": { "200": { "description": "Server status" } }
|
||||||
"200": { "description": "Server status" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"App": {
|
"LocalProject": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"hasDockerfile": { "type": "boolean" },
|
||||||
|
"hasDockerCompose": { "type": "boolean" },
|
||||||
|
"hasCoolifyJson": { "type": "boolean" },
|
||||||
|
"dockerStatus": { "type": "string", "enum": ["none", "partial", "configured"] },
|
||||||
|
"tarFile": { "type": "string", "nullable": true },
|
||||||
|
"serverId": { "type": "string", "nullable": true },
|
||||||
|
"remotePath": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Server": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"host": { "type": "string" },
|
||||||
|
"username": { "type": "string" },
|
||||||
|
"useSudo": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Container": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"status": { "type": "string" },
|
||||||
|
"ports": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DeployResult": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean" },
|
||||||
|
"healthy": { "type": "boolean" },
|
||||||
|
"status": { "type": "string" },
|
||||||
|
"uploadedFiles": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"message": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CoolifyApp": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"uuid": { "type": "string" },
|
"uuid": { "type": "string" },
|
||||||
@@ -290,19 +581,19 @@
|
|||||||
"_envs": { "type": "array" }
|
"_envs": { "type": "array" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CreateAppRequest": {
|
"CreateCoolifyAppRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "description": "App name in Coolify" },
|
"name": { "type": "string" },
|
||||||
"buildpack": { "type": "string", "enum": ["dockercompose", "nixpacks"], "default": "dockercompose" },
|
"buildpack": { "type": "string", "enum": ["dockercompose", "nixpacks"], "default": "dockercompose" },
|
||||||
"gitRepo": { "type": "string", "description": "Git SSH URL" },
|
"gitRepo": { "type": "string" },
|
||||||
"gitBranch": { "type": "string", "default": "master" },
|
"gitBranch": { "type": "string", "default": "master" },
|
||||||
"isStatic": { "type": "boolean", "default": false },
|
"isStatic": { "type": "boolean", "default": false },
|
||||||
"publishDir": { "type": "string", "default": "dist" },
|
"publishDir": { "type": "string", "default": "dist" },
|
||||||
"portsExposes": { "type": "string", "default": "3000" },
|
"portsExposes": { "type": "string", "default": "3000" },
|
||||||
"baseDirectory": { "type": "string", "default": "/" },
|
"baseDirectory": { "type": "string", "default": "/" },
|
||||||
"dockerComposeLocation": { "type": "string", "default": "/docker-compose.yml" },
|
"dockerComposeLocation": { "type": "string", "default": "/docker-compose.yml" },
|
||||||
"serverUuid": { "type": "string", "description": "Override server UUID" }
|
"serverUuid": { "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["name", "gitRepo"]
|
"required": ["name", "gitRepo"]
|
||||||
},
|
},
|
||||||
|
|||||||
231
api/routes/docker.js
Normal file
231
api/routes/docker.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
|
||||||
|
import { join, basename, dirname } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { loadDeployConfig, getServerSshConfig } from '../lib/config.js';
|
||||||
|
import { SSHService } from '../lib/ssh.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProjectDeployConfig(projectPath) {
|
||||||
|
const configPath = join(projectPath, 'docker-deployment.json');
|
||||||
|
if (!existsSync(configPath)) return null;
|
||||||
|
try { return JSON.parse(readFileSync(configPath, 'utf-8')); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadFiles(projectPath, projectConfig) {
|
||||||
|
const projectName = basename(projectPath);
|
||||||
|
const defaultFiles = [
|
||||||
|
{ local: `${projectName}.tar`, remote: `${projectName}.tar`, type: 'file' },
|
||||||
|
{ local: 'docker-compose.yml', remote: 'docker-compose.yml', type: 'file' },
|
||||||
|
{ local: '.env', remote: '.env', type: 'file' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const additional = projectConfig?.deployment?.uploadFiles || [];
|
||||||
|
const custom = additional.map(f => {
|
||||||
|
if (typeof f === 'string') {
|
||||||
|
const isDir = f.endsWith('/');
|
||||||
|
return { local: f.replace(/\/$/, ''), remote: f.replace(/\/$/, ''), type: isDir ? 'directory' : 'file' };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...defaultFiles, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/docker/build — build tar for a project
|
||||||
|
router.post('/build', wrap(async (req) => {
|
||||||
|
const { projectPath } = req.body;
|
||||||
|
if (!projectPath) throw Object.assign(new Error('projectPath is required'), { status: 400 });
|
||||||
|
|
||||||
|
const scriptPath = join(projectPath, 'build-image-tar.ps1');
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
throw Object.assign(new Error('No build-image-tar.ps1 found in project'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec(`powershell -ExecutionPolicy Bypass -File "${scriptPath}"`, { cwd: projectPath }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({ error: error.message, stderr });
|
||||||
|
} else {
|
||||||
|
resolve({ success: true, output: stdout });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/docker/deploy — deploy project to server via SSH
|
||||||
|
router.post('/deploy', wrap(async (req) => {
|
||||||
|
const { projectPath, serverId, remotePath } = req.body;
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === serverId);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
const projectName = basename(projectPath);
|
||||||
|
const password = sshConfig.password;
|
||||||
|
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
|
||||||
|
|
||||||
|
const projectConfig = loadProjectDeployConfig(projectPath);
|
||||||
|
const uploadFiles = getUploadFiles(projectPath, projectConfig);
|
||||||
|
const uploadedFiles = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
|
||||||
|
// Ensure remote directory exists
|
||||||
|
await ssh.exec(`mkdir -p ${remotePath}`);
|
||||||
|
|
||||||
|
// Delete old tar file with sudo if needed
|
||||||
|
if (server.useSudo) {
|
||||||
|
await ssh.exec(`echo '${password}' | sudo -S rm -f ${remotePath}/${projectName}.tar 2>/dev/null || true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload all configured files
|
||||||
|
for (const fileSpec of uploadFiles) {
|
||||||
|
const localPath = join(projectPath, fileSpec.local);
|
||||||
|
if (!existsSync(localPath)) continue;
|
||||||
|
|
||||||
|
const remoteDest = `${remotePath}/${fileSpec.remote}`;
|
||||||
|
|
||||||
|
if (fileSpec.type === 'directory') {
|
||||||
|
await ssh.uploadDirectory(localPath, remoteDest);
|
||||||
|
uploadedFiles.push(`${fileSpec.local}/ (directory)`);
|
||||||
|
} else {
|
||||||
|
await ssh.uploadFile(localPath, remoteDest);
|
||||||
|
uploadedFiles.push(fileSpec.local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load image, stop existing container, start new
|
||||||
|
await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker load -i ${projectName}.tar && ${sudoPrefix}docker compose down 2>/dev/null; ${sudoPrefix}docker compose up -d`);
|
||||||
|
|
||||||
|
// Health check — poll for container status
|
||||||
|
let healthy = false;
|
||||||
|
let status = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
status = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose ps --format "{{.Name}}|{{.Status}}" 2>/dev/null || ${sudoPrefix}docker compose ps`);
|
||||||
|
if (status.includes('Up') || status.includes('healthy')) {
|
||||||
|
healthy = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* ignore during health check */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh.disconnect();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
healthy,
|
||||||
|
status,
|
||||||
|
uploadedFiles,
|
||||||
|
message: healthy ? 'Container started successfully' : 'Container started but health check pending',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Deploy failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/docker/pull — pull file(s) from remote server
|
||||||
|
router.post('/pull', wrap(async (req) => {
|
||||||
|
const { serverId, files } = req.body;
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === serverId);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
const pulled = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (file.type === 'directory') {
|
||||||
|
// Pull directory recursively
|
||||||
|
const pullDir = async (remoteDir, localDir) => {
|
||||||
|
if (!existsSync(localDir)) mkdirSync(localDir, { recursive: true });
|
||||||
|
const result = await ssh.exec(`ls -la ${remoteDir} 2>/dev/null | tail -n +4 || echo ""`);
|
||||||
|
const lines = result.split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length < 9) continue;
|
||||||
|
const isDir = line.startsWith('d');
|
||||||
|
const fileName = parts.slice(8).join(' ');
|
||||||
|
if (fileName === '.' || fileName === '..') continue;
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
await pullDir(`${remoteDir}/${fileName}`, join(localDir, fileName));
|
||||||
|
} else {
|
||||||
|
await ssh.downloadFile(`${remoteDir}/${fileName}`, join(localDir, fileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await pullDir(file.remotePath, file.localPath);
|
||||||
|
pulled.push(file.name);
|
||||||
|
} else {
|
||||||
|
const parentDir = dirname(file.localPath);
|
||||||
|
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
||||||
|
await ssh.downloadFile(file.remotePath, file.localPath);
|
||||||
|
pulled.push(file.name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ name: file.name, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh.disconnect();
|
||||||
|
return { success: true, pulled, errors };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Pull failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/docker/vscode-diff — download remote file and open VS Code diff
|
||||||
|
router.post('/vscode-diff', wrap(async (req) => {
|
||||||
|
const { serverId, localPath, remoteFilePath } = req.body;
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === serverId);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempDir = join(tmpdir(), 'docker-deploy-diff');
|
||||||
|
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
||||||
|
const tempFile = join(tempDir, `remote-${basename(localPath)}`);
|
||||||
|
|
||||||
|
await ssh.connect();
|
||||||
|
await ssh.downloadFile(remoteFilePath, tempFile);
|
||||||
|
ssh.disconnect();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec(`code --diff "${tempFile}" "${localPath}"`, (error) => {
|
||||||
|
if (error) resolve({ error: error.message });
|
||||||
|
else resolve({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`VS Code diff failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default router;
|
||||||
258
api/routes/projects.js
Normal file
258
api/routes/projects.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { readdirSync, existsSync, readFileSync } from 'fs';
|
||||||
|
import { join, basename, relative } from 'path';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { loadDeployConfig, getProjectsRoot, getServerSshConfig } from '../lib/config.js';
|
||||||
|
import { SSHService } from '../lib/ssh.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project scanning (ported from app/main/project-scanner.js) ─────
|
||||||
|
|
||||||
|
function analyzeProject(projectPath, name) {
|
||||||
|
const hasDockerfile = existsSync(join(projectPath, 'Dockerfile'));
|
||||||
|
const hasDockerCompose = existsSync(join(projectPath, 'docker-compose.yml'));
|
||||||
|
const hasBuildScript = existsSync(join(projectPath, 'build-image-tar.ps1'));
|
||||||
|
const hasDeployScript = existsSync(join(projectPath, 'deploy-docker-auto.ps1'));
|
||||||
|
const hasDeploymentConfig = existsSync(join(projectPath, 'docker-deployment.json'));
|
||||||
|
const hasDockerIgnore = existsSync(join(projectPath, '.dockerignore'));
|
||||||
|
const hasCoolifyJson = existsSync(join(projectPath, 'coolify.json'));
|
||||||
|
|
||||||
|
let tarFile = null;
|
||||||
|
try {
|
||||||
|
const files = readdirSync(projectPath);
|
||||||
|
tarFile = files.find(f => f.endsWith('.tar')) || null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
let deploymentConfig = null;
|
||||||
|
if (hasDeploymentConfig) {
|
||||||
|
try {
|
||||||
|
deploymentConfig = JSON.parse(readFileSync(join(projectPath, 'docker-deployment.json'), 'utf-8'));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let coolifyConfig = null;
|
||||||
|
if (hasCoolifyJson) {
|
||||||
|
try {
|
||||||
|
coolifyConfig = JSON.parse(readFileSync(join(projectPath, 'coolify.json'), 'utf-8'));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dockerStatus = 'none';
|
||||||
|
if (hasDockerfile && hasDockerCompose) dockerStatus = 'configured';
|
||||||
|
else if (hasDockerfile || hasDockerCompose) dockerStatus = 'partial';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: projectPath,
|
||||||
|
hasDockerfile,
|
||||||
|
hasDockerCompose,
|
||||||
|
hasBuildScript,
|
||||||
|
hasDeployScript,
|
||||||
|
hasDeploymentConfig,
|
||||||
|
hasDockerIgnore,
|
||||||
|
hasCoolifyJson,
|
||||||
|
tarFile,
|
||||||
|
deploymentConfig,
|
||||||
|
coolifyConfig,
|
||||||
|
dockerStatus,
|
||||||
|
serverId: deploymentConfig?.deployment?.serverId || null,
|
||||||
|
remotePath: deploymentConfig?.deployment?.targetPath || `~/containers/${name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanDeep(rootPath, currentPath, projects, depth, maxDepth) {
|
||||||
|
if (depth > maxDepth) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
if (depth === 0) {
|
||||||
|
// At root: analyze each top-level directory
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const projectPath = join(currentPath, entry.name);
|
||||||
|
const info = analyzeProject(projectPath, entry.name);
|
||||||
|
if (info) projects.push(info);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Deeper: only include if has Dockerfile
|
||||||
|
const hasDockerfile = entries.some(e => e.name === 'Dockerfile');
|
||||||
|
if (hasDockerfile) {
|
||||||
|
const name = relative(rootPath, currentPath).replace(/\\/g, '/');
|
||||||
|
const info = analyzeProject(currentPath, name);
|
||||||
|
if (info) projects.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subdirectories
|
||||||
|
if (depth < maxDepth) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
if (entry.name.startsWith('.') || ['node_modules', 'dist', 'build'].includes(entry.name)) continue;
|
||||||
|
scanDeep(rootPath, join(currentPath, entry.name), projects, depth + 1, maxDepth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore permission errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Routes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/projects — scan local projects
|
||||||
|
router.get('/', wrap(async () => {
|
||||||
|
const rootPath = getProjectsRoot();
|
||||||
|
const projects = [];
|
||||||
|
scanDeep(rootPath, rootPath, projects, 0, 2);
|
||||||
|
return projects;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/projects/compare — compare local vs deployed
|
||||||
|
router.post('/compare', wrap(async (req) => {
|
||||||
|
const { projectPath, serverId, remotePath } = req.body;
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === serverId);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
|
||||||
|
// Load project config for additional files
|
||||||
|
const deployConfigPath = join(projectPath, 'docker-deployment.json');
|
||||||
|
let additionalFiles = [];
|
||||||
|
if (existsSync(deployConfigPath)) {
|
||||||
|
try {
|
||||||
|
const pc = JSON.parse(readFileSync(deployConfigPath, 'utf-8'));
|
||||||
|
additionalFiles = pc?.deployment?.uploadFiles || [];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = { files: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
|
||||||
|
// Compare docker-compose.yml
|
||||||
|
const localComposePath = join(projectPath, 'docker-compose.yml');
|
||||||
|
const composeFile = { name: 'docker-compose.yml', type: 'file', status: 'unknown', localPath: localComposePath, remotePath: `${remotePath}/docker-compose.yml` };
|
||||||
|
|
||||||
|
if (existsSync(localComposePath)) {
|
||||||
|
composeFile.localContent = readFileSync(localComposePath, 'utf-8');
|
||||||
|
try {
|
||||||
|
composeFile.remoteContent = await ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`);
|
||||||
|
composeFile.status = composeFile.localContent.trim() === composeFile.remoteContent.trim() ? 'match' : 'different';
|
||||||
|
} catch {
|
||||||
|
composeFile.status = 'remote-missing';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
composeFile.status = 'local-missing';
|
||||||
|
}
|
||||||
|
diff.files.push(composeFile);
|
||||||
|
|
||||||
|
// Compare .env
|
||||||
|
const localEnvPath = join(projectPath, '.env');
|
||||||
|
const envFile = { name: '.env', type: 'file', status: 'unknown', localPath: localEnvPath, remotePath: `${remotePath}/.env`, sensitive: true };
|
||||||
|
const hasLocalEnv = existsSync(localEnvPath);
|
||||||
|
if (hasLocalEnv) envFile.localContent = readFileSync(localEnvPath, 'utf-8');
|
||||||
|
|
||||||
|
let hasRemoteEnv = false;
|
||||||
|
try {
|
||||||
|
await ssh.exec(`test -f ${remotePath}/.env`);
|
||||||
|
hasRemoteEnv = true;
|
||||||
|
try { envFile.remoteContent = await ssh.exec(`cat ${remotePath}/.env 2>/dev/null`); } catch { /* ignore */ }
|
||||||
|
} catch { /* no remote .env */ }
|
||||||
|
|
||||||
|
if (hasLocalEnv && hasRemoteEnv) {
|
||||||
|
envFile.status = (envFile.localContent && envFile.remoteContent && envFile.localContent.trim() === envFile.remoteContent.trim()) ? 'match' : 'different';
|
||||||
|
} else if (hasLocalEnv) {
|
||||||
|
envFile.status = 'remote-missing';
|
||||||
|
} else if (hasRemoteEnv) {
|
||||||
|
envFile.status = 'local-missing';
|
||||||
|
} else {
|
||||||
|
envFile.status = 'neither';
|
||||||
|
}
|
||||||
|
diff.files.push(envFile);
|
||||||
|
|
||||||
|
// Compare data directory
|
||||||
|
const localDataPath = join(projectPath, 'data');
|
||||||
|
const hasLocalData = existsSync(localDataPath);
|
||||||
|
let hasRemoteData = false;
|
||||||
|
try { await ssh.exec(`test -d ${remotePath}/data`); hasRemoteData = true; } catch { /* no */ }
|
||||||
|
|
||||||
|
diff.files.push({
|
||||||
|
name: 'data/',
|
||||||
|
type: 'directory',
|
||||||
|
status: hasLocalData && hasRemoteData ? 'both-exist' : hasLocalData ? 'remote-missing' : hasRemoteData ? 'local-missing' : 'neither',
|
||||||
|
localPath: localDataPath,
|
||||||
|
remotePath: `${remotePath}/data`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional files from project config
|
||||||
|
for (const fileSpec of additionalFiles) {
|
||||||
|
const fileName = typeof fileSpec === 'string' ? fileSpec : fileSpec.local;
|
||||||
|
if (['docker-compose.yml', '.env', 'data', 'data/'].includes(fileName)) continue;
|
||||||
|
|
||||||
|
const isDir = fileName.endsWith('/');
|
||||||
|
const cleanName = fileName.replace(/\/$/, '');
|
||||||
|
const localFilePath = join(projectPath, cleanName);
|
||||||
|
const remoteFilePath = `${remotePath}/${cleanName}`;
|
||||||
|
const fileInfo = { name: fileName, type: isDir ? 'directory' : 'file', status: 'unknown', localPath: localFilePath, remotePath: remoteFilePath };
|
||||||
|
const hasLocal = existsSync(localFilePath);
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
let hasRemote = false;
|
||||||
|
try { await ssh.exec(`test -d ${remoteFilePath}`); hasRemote = true; } catch { /* no */ }
|
||||||
|
fileInfo.status = hasLocal && hasRemote ? 'both-exist' : hasLocal ? 'remote-missing' : hasRemote ? 'local-missing' : 'neither';
|
||||||
|
} else {
|
||||||
|
if (hasLocal) try { fileInfo.localContent = readFileSync(localFilePath, 'utf-8'); } catch { fileInfo.localContent = null; }
|
||||||
|
try {
|
||||||
|
fileInfo.remoteContent = await ssh.exec(`cat ${remoteFilePath} 2>/dev/null`);
|
||||||
|
if (hasLocal && fileInfo.localContent != null) {
|
||||||
|
fileInfo.status = fileInfo.localContent.trim() === fileInfo.remoteContent.trim() ? 'match' : 'different';
|
||||||
|
} else {
|
||||||
|
fileInfo.status = hasLocal ? 'different' : 'local-missing';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fileInfo.status = hasLocal ? 'remote-missing' : 'neither';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff.files.push(fileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh.disconnect();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Compare failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, diff };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/projects/init — initialize a project with CLI tool
|
||||||
|
router.post('/init', wrap(async (req) => {
|
||||||
|
const { projectPath } = req.body;
|
||||||
|
if (!projectPath) throw Object.assign(new Error('projectPath is required'), { status: 400 });
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const cliPath = join(getProjectsRoot(), '..', 'idea.llm.gitea.repo.docker.deployment');
|
||||||
|
const cmd = `node cli/index.js init "${projectPath}" --no-interactive`;
|
||||||
|
exec(cmd, { cwd: cliPath }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({ error: error.message, stderr });
|
||||||
|
} else {
|
||||||
|
resolve({ success: true, output: stdout + (stderr || '') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default router;
|
||||||
153
api/routes/servers.js
Normal file
153
api/routes/servers.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { loadDeployConfig, saveDeployConfig, getServerSshConfig } from '../lib/config.js';
|
||||||
|
import { SSHService } from '../lib/ssh.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/servers — list all configured deployment servers
|
||||||
|
router.get('/', wrap(async () => {
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
return config.servers || [];
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/servers — add or update a server
|
||||||
|
router.post('/', wrap(async (req) => {
|
||||||
|
const server = req.body;
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
if (!config.servers) config.servers = [];
|
||||||
|
|
||||||
|
const index = config.servers.findIndex(s => s.id === server.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
config.servers[index] = server;
|
||||||
|
} else {
|
||||||
|
server.id = Date.now().toString();
|
||||||
|
config.servers.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDeployConfig(config);
|
||||||
|
return { success: true, server };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /api/servers/:id — delete a server
|
||||||
|
router.delete('/:id', wrap(async (req) => {
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
config.servers = (config.servers || []).filter(s => s.id !== req.params.id);
|
||||||
|
saveDeployConfig(config);
|
||||||
|
return { success: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/servers/:id/scan — scan remote server for deployed containers
|
||||||
|
router.get('/:id/scan', wrap(async (req) => {
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
|
||||||
|
const result = await ssh.exec('ls -1 ~/containers 2>/dev/null || echo ""');
|
||||||
|
const projectDirs = result.split('\n').filter(Boolean);
|
||||||
|
const deployed = [];
|
||||||
|
|
||||||
|
for (const dir of projectDirs) {
|
||||||
|
const remotePath = `~/containers/${dir}`;
|
||||||
|
try {
|
||||||
|
const filesResult = await ssh.exec(`ls -la ${remotePath} 2>/dev/null || echo ""`);
|
||||||
|
const hasDockerCompose = filesResult.includes('docker-compose.yml');
|
||||||
|
const hasEnv = filesResult.includes('.env');
|
||||||
|
const hasData = filesResult.includes('data');
|
||||||
|
const tarMatch = filesResult.match(/(\S+\.tar)/);
|
||||||
|
|
||||||
|
let dockerComposeContent = null;
|
||||||
|
if (hasDockerCompose) {
|
||||||
|
try { dockerComposeContent = await ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
deployed.push({
|
||||||
|
name: dir,
|
||||||
|
remotePath,
|
||||||
|
hasDockerCompose,
|
||||||
|
hasEnv,
|
||||||
|
hasData,
|
||||||
|
tarFile: tarMatch ? tarMatch[1] : null,
|
||||||
|
dockerComposeContent,
|
||||||
|
});
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh.disconnect();
|
||||||
|
return { success: true, deployed };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Scan failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/servers/:id/containers — list running docker containers
|
||||||
|
router.get('/:id/containers', wrap(async (req) => {
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
const result = await ssh.exec('docker ps --format "{{.Names}}|{{.Status}}|{{.Ports}}"');
|
||||||
|
ssh.disconnect();
|
||||||
|
|
||||||
|
const containers = result.split('\n').filter(Boolean).map(line => {
|
||||||
|
const [name, status, ports] = line.split('|');
|
||||||
|
return { name, status, ports };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, containers };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to get containers: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/servers/:id/logs — get container logs
|
||||||
|
router.get('/:id/logs', wrap(async (req) => {
|
||||||
|
const config = loadDeployConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
|
||||||
|
|
||||||
|
const { containerName, remotePath, lines = 100 } = req.query;
|
||||||
|
const sshConfig = getServerSshConfig(server);
|
||||||
|
const ssh = new SSHService(sshConfig);
|
||||||
|
const password = sshConfig.password;
|
||||||
|
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ssh.connect();
|
||||||
|
let logs;
|
||||||
|
if (remotePath) {
|
||||||
|
logs = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose logs --tail ${lines} 2>&1`);
|
||||||
|
} else if (containerName) {
|
||||||
|
logs = await ssh.exec(`${sudoPrefix}docker logs ${containerName} --tail ${lines} 2>&1`);
|
||||||
|
} else {
|
||||||
|
throw Object.assign(new Error('containerName or remotePath required'), { status: 400 });
|
||||||
|
}
|
||||||
|
ssh.disconnect();
|
||||||
|
return { success: true, logs };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to get logs: ${err.message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -5,6 +5,9 @@ import { readFileSync } from 'fs';
|
|||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import coolifyRoutes from './routes/coolify.js';
|
import coolifyRoutes from './routes/coolify.js';
|
||||||
|
import projectRoutes from './routes/projects.js';
|
||||||
|
import serverRoutes from './routes/servers.js';
|
||||||
|
import dockerRoutes from './routes/docker.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const spec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf8'));
|
const spec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf8'));
|
||||||
@@ -13,10 +16,13 @@ const app = express();
|
|||||||
const PORT = process.env.PORT || 3100;
|
const PORT = process.env.PORT || 3100;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
app.use('/api/coolify', coolifyRoutes);
|
app.use('/api/coolify', coolifyRoutes);
|
||||||
|
app.use('/api/projects', projectRoutes);
|
||||||
|
app.use('/api/servers', serverRoutes);
|
||||||
|
app.use('/api/docker', dockerRoutes);
|
||||||
|
|
||||||
// OpenAPI spec
|
// OpenAPI spec
|
||||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
app.get('/openapi.json', (req, res) => res.json(spec));
|
||||||
|
|||||||
@@ -1,45 +1,4 @@
|
|||||||
const api = window.api;
|
// ─── Shared fetch helper ────────────────────────────────────────────
|
||||||
|
|
||||||
export const serverApi = {
|
|
||||||
getAll: () => api.getServers(),
|
|
||||||
save: (server) => api.saveServer(server),
|
|
||||||
delete: (id) => api.deleteServer(id),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectApi = {
|
|
||||||
scanLocal: () => api.scanLocalProjects(),
|
|
||||||
scanServer: (serverId) => api.scanServer(serverId),
|
|
||||||
getRunningContainers: (serverId) => api.getRunningContainers(serverId),
|
|
||||||
compare: (data) => api.compareProject(data),
|
|
||||||
init: (projectPath) => api.initProject(projectPath),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deployApi = {
|
|
||||||
buildTar: (projectPath) => api.buildTar(projectPath),
|
|
||||||
deploy: (data) => api.deployProject(data),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const syncApi = {
|
|
||||||
pullFile: (data) => api.pullFile(data),
|
|
||||||
pullFiles: (data) => api.pullFiles(data),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logsApi = {
|
|
||||||
getContainerLogs: (data) => api.getContainerLogs(data),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const configApi = {
|
|
||||||
get: () => api.getConfig(),
|
|
||||||
save: (config) => api.saveConfig(config),
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
async function fetchJson(url, options) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -53,6 +12,77 @@ async function fetchJson(url, options) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Deployment Servers (CRUD + scanning) ───────────────────────────
|
||||||
|
|
||||||
|
export const serverApi = {
|
||||||
|
getAll: () => fetchJson('/api/servers'),
|
||||||
|
save: (server) => fetchJson('/api/servers', { method: 'POST', body: JSON.stringify(server) }),
|
||||||
|
delete: (id) => fetchJson(`/api/servers/${id}`, { method: 'DELETE' }),
|
||||||
|
scan: (serverId) => fetchJson(`/api/servers/${serverId}/scan`),
|
||||||
|
getRunningContainers: (serverId) => fetchJson(`/api/servers/${serverId}/containers`),
|
||||||
|
getLogs: ({ serverId, containerName, remotePath, lines }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (containerName) params.set('containerName', containerName);
|
||||||
|
if (remotePath) params.set('remotePath', remotePath);
|
||||||
|
if (lines) params.set('lines', String(lines));
|
||||||
|
return fetchJson(`/api/servers/${serverId}/logs?${params}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Projects (scan, compare, init) ────────────────────────────────
|
||||||
|
|
||||||
|
export const projectApi = {
|
||||||
|
scanLocal: () => fetchJson('/api/projects'),
|
||||||
|
scanServer: (serverId) => fetchJson(`/api/servers/${serverId}/scan`).then(r => r.deployed || []),
|
||||||
|
getRunningContainers: (serverId) => fetchJson(`/api/servers/${serverId}/containers`).then(r => r.containers || []),
|
||||||
|
compare: (data) => fetchJson('/api/projects/compare', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
init: (projectPath) => fetchJson('/api/projects/init', { method: 'POST', body: JSON.stringify({ projectPath }) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Docker (build, deploy, pull) ──────────────────────────────────
|
||||||
|
|
||||||
|
export const deployApi = {
|
||||||
|
buildTar: (projectPath) => fetchJson('/api/docker/build', { method: 'POST', body: JSON.stringify({ projectPath }) }),
|
||||||
|
deploy: (data) => fetchJson('/api/docker/deploy', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncApi = {
|
||||||
|
pullFile: (data) => fetchJson('/api/docker/pull', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverId: data.serverId, files: [{ name: data.remotePath, remotePath: data.remotePath, localPath: data.localPath, type: data.isDirectory ? 'directory' : 'file' }] }),
|
||||||
|
}).then(r => r),
|
||||||
|
pullFiles: (data) => fetchJson('/api/docker/pull', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Config ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const configApi = {
|
||||||
|
get: () => fetchJson('/api/servers').then(servers => {
|
||||||
|
// Reconstruct config shape from servers list
|
||||||
|
return { servers };
|
||||||
|
}),
|
||||||
|
save: (config) => {
|
||||||
|
// Save each server individually
|
||||||
|
return Promise.all((config.servers || []).map(s => serverApi.save(s)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logsApi = {
|
||||||
|
getContainerLogs: (data) => serverApi.getLogs(data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Coolify API (HTTP fetch to Express server) ────────────────────
|
||||||
|
|
||||||
|
const COOLIFY_BASE = '/api/coolify';
|
||||||
|
|
||||||
|
// ─── Tools (VS Code diff — opens locally, not via API) ─────────────
|
||||||
|
|
||||||
|
export const toolsApi = {
|
||||||
|
openVSCodeDiff: (data) => fetchJson('/api/docker/vscode-diff', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Coolify API (HTTP fetch to Express server) ────────────────────
|
||||||
|
|
||||||
export const coolifyApi = {
|
export const coolifyApi = {
|
||||||
listApps: () => fetchJson(`${COOLIFY_BASE}/apps`),
|
listApps: () => fetchJson(`${COOLIFY_BASE}/apps`),
|
||||||
findApp: (name) => fetchJson(`${COOLIFY_BASE}/apps/find/${encodeURIComponent(name)}`),
|
findApp: (name) => fetchJson(`${COOLIFY_BASE}/apps/find/${encodeURIComponent(name)}`),
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
"main": "cli/index.js",
|
"main": "cli/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docker-deploy": "node cli/index.js",
|
"dev": "concurrently -n api,vite,electron -c blue,green,magenta \"npm run api:dev\" \"npm run vite:dev\" \"npm run electron:dev\"",
|
||||||
|
"dev:no-electron": "concurrently -n api,vite -c blue,green \"npm run api:dev\" \"npm run vite:dev\"",
|
||||||
"api": "node api/server.js",
|
"api": "node api/server.js",
|
||||||
"api:dev": "node --watch api/server.js",
|
"api:dev": "node --watch api/server.js",
|
||||||
|
"vite:dev": "cd app/renderer && npx vite",
|
||||||
|
"electron:dev": "npx wait-on http://localhost:5173 && electron app/main/index.js --dev",
|
||||||
|
"docker-deploy": "node cli/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"@scalar/express-api-reference": "^0.8.46",
|
"@scalar/express-api-reference": "^0.8.46",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user