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;