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:
2026-02-27 11:17:40 -06:00
parent 93d40455d9
commit feec35ffce
9 changed files with 1329 additions and 153 deletions

258
api/routes/projects.js Normal file
View 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;