Files
idea.llm.gitea.repo.docker.…/api/routes/projects.js
Clint Masden feec35ffce 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>
2026-02-27 11:17:40 -06:00

259 lines
9.6 KiB
JavaScript

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;