From feec35ffcedcca805f68a2bc7fa084175a389c71 Mon Sep 17 00:00:00 2001 From: Clint Masden Date: Fri, 27 Feb 2026 11:17:40 -0600 Subject: [PATCH] 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 --- api/lib/config.js | 77 +++++- api/lib/ssh.js | 153 +++++++++++- api/openapi.json | 481 +++++++++++++++++++++++++++++------- api/routes/docker.js | 231 +++++++++++++++++ api/routes/projects.js | 258 +++++++++++++++++++ api/routes/servers.js | 153 ++++++++++++ api/server.js | 8 +- app/renderer/src/lib/api.js | 114 +++++---- package.json | 7 +- 9 files changed, 1329 insertions(+), 153 deletions(-) create mode 100644 api/routes/docker.js create mode 100644 api/routes/projects.js create mode 100644 api/routes/servers.js diff --git a/api/lib/config.js b/api/lib/config.js index 0ecbe25..65f76d7 100644 --- a/api/lib/config.js +++ b/api/lib/config.js @@ -1,24 +1,81 @@ -import { readFileSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(__dirname, '..', '..'); -// Shared config from gitea.repo.management -const CONFIG_PATH = join(__dirname, '..', '..', '..', 'gitea.repo.management', 'config.json'); +// Shared Coolify config from gitea.repo.management +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() { - if (_cached) return _cached; - if (!existsSync(CONFIG_PATH)) { - throw new Error(`Config not found at ${CONFIG_PATH}`); + if (_coolifyCache) return _coolifyCache; + if (!existsSync(COOLIFY_CONFIG_PATH)) { + throw new Error(`Coolify config not found at ${COOLIFY_CONFIG_PATH}`); } - _cached = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); - return _cached; + _coolifyCache = JSON.parse(readFileSync(COOLIFY_CONFIG_PATH, 'utf8')); + return _coolifyCache; } export function reloadConfig() { - _cached = null; + _coolifyCache = null; 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, + }; +} diff --git a/api/lib/ssh.js b/api/lib/ssh.js index 7825cc4..0c0a08f 100644 --- a/api/lib/ssh.js +++ b/api/lib/ssh.js @@ -1,9 +1,155 @@ 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. - * Uses the ssh2 library (already a project dependency) instead of - * the plink/sshpass fallback chain from gitea.repo.management. + * SSHService — wraps ssh2 Client with exec + SFTP operations. + * Ported from app/main/ssh-service.js to ESM for the API server. + */ +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) { const host = config.coolify?.sshHost; @@ -39,7 +185,6 @@ export function sshExec(config, command) { }); conn.on('error', (err) => reject(err)); - conn.connect({ host, port: 22, username: user, password }); }); } diff --git a/api/openapi.json b/api/openapi.json index a537b91..26a3e5a 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1,39 +1,312 @@ { "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" + "title": "Docker Deployment & Coolify API", + "description": "REST API for managing Docker deployments, Coolify applications, Traefik routing, and server management. Used by both the desktop UI and LLM agents.", + "version": "2.0.0" }, "servers": [ { "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": { - "/api/coolify/apps": { + "/api/projects": { "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"], + "operationId": "scanLocalProjects", + "summary": "Scan local projects", + "description": "Scans the configured projectsRoot directory for projects with Dockerfiles, docker-compose files, coolify.json, etc.", + "tags": ["Projects"], "responses": { "200": { - "description": "Array of Coolify applications", - "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/App" } } } } + "description": "Array of local project info", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LocalProject" } } } } } } - }, + } + }, + "/api/projects/compare": { "post": { - "operationId": "createApp", - "summary": "Create a new Coolify application", - "tags": ["Apps"], + "operationId": "compareProject", + "summary": "Compare local vs deployed files", + "description": "Compares docker-compose.yml, .env, data/, and additional configured files between local and remote.", + "tags": ["Projects"], "requestBody": { "required": true, "content": { "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": { "200": { "description": "Created app object" } } @@ -41,9 +314,9 @@ }, "/api/coolify/apps/find/{name}": { "get": { - "operationId": "findApp", - "summary": "Find app by name", - "tags": ["Apps"], + "operationId": "findCoolifyApp", + "summary": "Find Coolify app by name", + "tags": ["Coolify Apps"], "parameters": [ { "name": "name", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -54,9 +327,9 @@ }, "/api/coolify/apps/{uuid}": { "patch": { - "operationId": "updateApp", - "summary": "Update an existing app", - "tags": ["Apps"], + "operationId": "updateCoolifyApp", + "summary": "Update an existing Coolify app", + "tags": ["Coolify Apps"], "parameters": [ { "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -64,38 +337,32 @@ "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, - "responses": { - "200": { "description": "Updated app" } - } + "responses": { "200": { "description": "Updated app" } } }, "delete": { - "operationId": "deleteApp", - "summary": "Delete an app from Coolify", - "tags": ["Apps"], + "operationId": "deleteCoolifyApp", + "summary": "Delete a Coolify app", + "tags": ["Coolify Apps"], "parameters": [ { "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { "description": "Deletion confirmation" } - } + "responses": { "200": { "description": "Deletion confirmation" } } } }, "/api/coolify/apps/{uuid}/envs": { "get": { - "operationId": "listEnvs", - "summary": "List env vars for an app", - "tags": ["Environment"], + "operationId": "listCoolifyEnvs", + "summary": "List env vars for a Coolify app", + "tags": ["Coolify Environment"], "parameters": [ { "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { "description": "Array of env vars" } - } + "responses": { "200": { "description": "Array of env vars" } } }, "post": { - "operationId": "setEnv", + "operationId": "setCoolifyEnv", "summary": "Set an environment variable", - "tags": ["Environment"], + "tags": ["Coolify Environment"], "parameters": [ { "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -114,40 +381,34 @@ } } }, - "responses": { - "200": { "description": "Env var created/updated" } - } + "responses": { "200": { "description": "Env var created/updated" } } } }, "/api/coolify/apps/{uuid}/deploy": { "post": { - "operationId": "deployApp", - "summary": "Trigger deployment for an app", - "tags": ["Deploy"], + "operationId": "deployCoolifyApp", + "summary": "Trigger Coolify deployment", + "tags": ["Coolify Deploy"], "parameters": [ { "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { "description": "Deployment triggered" } - } + "responses": { "200": { "description": "Deployment triggered" } } } }, "/api/coolify/servers": { "get": { - "operationId": "listServers", + "operationId": "listCoolifyServers", "summary": "List all Coolify servers", - "tags": ["Servers"], - "responses": { - "200": { "description": "Array of servers" } - } + "tags": ["Coolify Infrastructure"], + "responses": { "200": { "description": "Array of Coolify 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"], + "description": "Scans Coolify env vars and Traefik config to find the highest used port.", + "tags": ["Coolify Infrastructure"], "responses": { "200": { "description": "Next port info", @@ -168,9 +429,9 @@ }, "/api/coolify/routes": { "post": { - "operationId": "addRoute", + "operationId": "addTraefikRoute", "summary": "Add a Traefik route via SSH", - "tags": ["Infrastructure"], + "tags": ["Coolify Infrastructure"], "requestBody": { "required": true, "content": { @@ -178,25 +439,23 @@ "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" } + "routeName": { "type": "string" }, + "domain": { "type": "string" }, + "port": { "type": "integer" } }, "required": ["routeName", "domain", "port"] } } } }, - "responses": { - "200": { "description": "Route added or already exists" } - } + "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"], + "summary": "Check drift between coolify.json and live state", + "tags": ["Coolify Deploy"], "requestBody": { "required": true, "content": { @@ -204,25 +463,23 @@ "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" } + "projectPath": { "type": "string" }, + "appName": { "type": "string" } }, "required": ["projectPath"] } } } }, - "responses": { - "200": { "description": "Drift check result with diffs" } - } + "responses": { "200": { "description": "Drift check result" } } } }, "/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"], + "operationId": "upsertCoolifyApp", + "summary": "Full deploy pipeline: config → create/update → env → route → deploy", + "description": "Reads coolify.json, creates or updates the Coolify app, sets HOST_PORT, configures Traefik, writes changelog, and triggers deployment.", + "tags": ["Coolify Deploy"], "requestBody": { "required": true, "content": { @@ -230,8 +487,8 @@ "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" } + "projectPath": { "type": "string" }, + "appName": { "type": "string" } }, "required": ["projectPath"] } @@ -240,12 +497,8 @@ }, "responses": { "200": { - "description": "Upsert result with steps and changelog", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UpsertResult" } - } - } + "description": "Upsert result with steps", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpsertResult" } } } } } } @@ -253,14 +506,12 @@ "/api/coolify/config": { "get": { "operationId": "readCoolifyConfig", - "summary": "Read coolify.json from a project directory", - "tags": ["Config"], + "summary": "Read coolify.json from a project", + "tags": ["System"], "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": { - "200": { "description": "Parsed coolify.json contents" } - } + "responses": { "200": { "description": "Parsed coolify.json" } } } }, "/api/health": { @@ -268,15 +519,55 @@ "operationId": "healthCheck", "summary": "Health check", "tags": ["System"], - "responses": { - "200": { "description": "Server status" } - } + "responses": { "200": { "description": "Server status" } } } } }, "components": { "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", "properties": { "uuid": { "type": "string" }, @@ -290,19 +581,19 @@ "_envs": { "type": "array" } } }, - "CreateAppRequest": { + "CreateCoolifyAppRequest": { "type": "object", "properties": { - "name": { "type": "string", "description": "App name in Coolify" }, + "name": { "type": "string" }, "buildpack": { "type": "string", "enum": ["dockercompose", "nixpacks"], "default": "dockercompose" }, - "gitRepo": { "type": "string", "description": "Git SSH URL" }, + "gitRepo": { "type": "string" }, "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" } + "serverUuid": { "type": "string" } }, "required": ["name", "gitRepo"] }, diff --git a/api/routes/docker.js b/api/routes/docker.js new file mode 100644 index 0000000..16c0c7c --- /dev/null +++ b/api/routes/docker.js @@ -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; diff --git a/api/routes/projects.js b/api/routes/projects.js new file mode 100644 index 0000000..058e4b1 --- /dev/null +++ b/api/routes/projects.js @@ -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; diff --git a/api/routes/servers.js b/api/routes/servers.js new file mode 100644 index 0000000..75b8621 --- /dev/null +++ b/api/routes/servers.js @@ -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; diff --git a/api/server.js b/api/server.js index dfc1011..63ee214 100644 --- a/api/server.js +++ b/api/server.js @@ -5,6 +5,9 @@ import { readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; 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 spec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf8')); @@ -13,10 +16,13 @@ const app = express(); const PORT = process.env.PORT || 3100; app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '50mb' })); // API routes app.use('/api/coolify', coolifyRoutes); +app.use('/api/projects', projectRoutes); +app.use('/api/servers', serverRoutes); +app.use('/api/docker', dockerRoutes); // OpenAPI spec app.get('/openapi.json', (req, res) => res.json(spec)); diff --git a/app/renderer/src/lib/api.js b/app/renderer/src/lib/api.js index 6acdde2..f36615f 100644 --- a/app/renderer/src/lib/api.js +++ b/app/renderer/src/lib/api.js @@ -1,45 +1,4 @@ -const api = window.api; - -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'; +// ─── Shared fetch helper ──────────────────────────────────────────── async function fetchJson(url, options) { const res = await fetch(url, { @@ -53,6 +12,77 @@ async function fetchJson(url, options) { 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 = { listApps: () => fetchJson(`${COOLIFY_BASE}/apps`), findApp: (name) => fetchJson(`${COOLIFY_BASE}/apps/find/${encodeURIComponent(name)}`), diff --git a/package.json b/package.json index b230132..d7c5496 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "main": "cli/index.js", "type": "module", "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: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" }, "keywords": [ @@ -23,6 +27,7 @@ "@scalar/express-api-reference": "^0.8.46", "chalk": "^5.3.0", "commander": "^12.1.0", + "concurrently": "^9.2.1", "cors": "^2.8.6", "express": "^5.2.1", "glob": "^11.0.0",