const { SSHService } = require('./ssh-service'); const fs = require('fs'); const path = require('path'); class ServerScanner { constructor(sshConfig) { this.sshConfig = sshConfig; this.ssh = new SSHService(sshConfig); } async scan() { const deployed = []; try { await this.ssh.connect(); // List directories in ~/containers const result = await this.ssh.exec('ls -1 ~/containers 2>/dev/null || echo ""'); const projectDirs = result.split('\n').filter(Boolean); for (const dir of projectDirs) { const remotePath = `~/containers/${dir}`; const info = await this.getProjectInfo(remotePath, dir); if (info) { deployed.push(info); } } this.ssh.disconnect(); } catch (err) { console.error('Failed to scan server:', err); throw err; } return deployed; } async getProjectInfo(remotePath, name) { try { // Check what files exist const filesResult = await this.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)/); const tarFile = tarMatch ? tarMatch[1] : null; // Get docker-compose.yml content if exists let dockerComposeContent = null; if (hasDockerCompose) { try { dockerComposeContent = await this.ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); } catch (err) { // ignore } } return { name, remotePath, hasDockerCompose, hasEnv, hasData, tarFile, dockerComposeContent }; } catch (err) { return null; } } async compareFiles(localProjectPath, remotePath, additionalFiles = []) { const diff = { dockerCompose: { status: 'unknown', localContent: null, remoteContent: null }, env: { status: 'unknown' }, data: { status: 'unknown' }, // New: array of all compared files for UI files: [] }; try { await this.ssh.connect(); // Compare docker-compose.yml const localComposePath = path.join(localProjectPath, 'docker-compose.yml'); if (fs.existsSync(localComposePath)) { diff.dockerCompose.localContent = fs.readFileSync(localComposePath, 'utf-8'); try { diff.dockerCompose.remoteContent = await this.ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); if (diff.dockerCompose.localContent.trim() === diff.dockerCompose.remoteContent.trim()) { diff.dockerCompose.status = 'match'; } else { diff.dockerCompose.status = 'different'; } } catch (err) { diff.dockerCompose.status = 'remote-missing'; } } else { diff.dockerCompose.status = 'local-missing'; } // Add to files array diff.files.push({ name: 'docker-compose.yml', type: 'file', status: diff.dockerCompose.status, localContent: diff.dockerCompose.localContent, remoteContent: diff.dockerCompose.remoteContent, localPath: localComposePath, remotePath: `${remotePath}/docker-compose.yml` }); // Check .env exists on both and compare content const localEnvPath = path.join(localProjectPath, '.env'); const hasLocalEnv = fs.existsSync(localEnvPath); // Read local .env content if (hasLocalEnv) { diff.env.localContent = fs.readFileSync(localEnvPath, 'utf-8'); } // Check and read remote .env try { await this.ssh.exec(`test -f ${remotePath}/.env`); diff.env.hasRemote = true; try { diff.env.remoteContent = await this.ssh.exec(`cat ${remotePath}/.env 2>/dev/null`); } catch (err) { diff.env.remoteContent = null; } } catch (err) { diff.env.hasRemote = false; } diff.env.hasLocal = hasLocalEnv; // Determine status based on content comparison if (hasLocalEnv && diff.env.hasRemote) { if (diff.env.localContent && diff.env.remoteContent) { diff.env.status = diff.env.localContent.trim() === diff.env.remoteContent.trim() ? 'match' : 'different'; } else { diff.env.status = 'both-exist'; } } else if (hasLocalEnv) { diff.env.status = 'remote-missing'; } else if (diff.env.hasRemote) { diff.env.status = 'local-missing'; } else { diff.env.status = 'neither'; } // Add to files array diff.files.push({ name: '.env', type: 'file', status: diff.env.status, localContent: diff.env.localContent, remoteContent: diff.env.remoteContent, localPath: localEnvPath, remotePath: `${remotePath}/.env`, sensitive: true // Mark as sensitive for masking in UI }); // Check data directory const localDataPath = path.join(localProjectPath, 'data'); const hasLocalData = fs.existsSync(localDataPath); try { await this.ssh.exec(`test -d ${remotePath}/data`); diff.data.hasRemote = true; } catch (err) { diff.data.hasRemote = false; } diff.data.hasLocal = hasLocalData; diff.data.status = hasLocalData && diff.data.hasRemote ? 'both-exist' : hasLocalData ? 'remote-missing' : diff.data.hasRemote ? 'local-missing' : 'neither'; // Add to files array diff.files.push({ name: 'data/', type: 'directory', status: diff.data.status, localPath: localDataPath, remotePath: `${remotePath}/data` }); // Compare additional files from project config for (const fileSpec of additionalFiles) { const fileName = typeof fileSpec === 'string' ? fileSpec : fileSpec.local; const isDir = fileName.endsWith('/'); const cleanName = fileName.replace(/\/$/, ''); const localFilePath = path.join(localProjectPath, cleanName); const remoteFilePath = `${remotePath}/${cleanName}`; // Skip if already compared (default files) if (['docker-compose.yml', '.env', 'data', 'data/'].includes(fileName)) { continue; } const fileInfo = { name: fileName, type: isDir ? 'directory' : 'file', status: 'unknown', localPath: localFilePath, remotePath: remoteFilePath }; const hasLocal = fs.existsSync(localFilePath); if (isDir) { // Directory comparison let hasRemote = false; try { await this.ssh.exec(`test -d ${remoteFilePath}`); hasRemote = true; } catch (err) { hasRemote = false; } fileInfo.status = hasLocal && hasRemote ? 'both-exist' : hasLocal ? 'remote-missing' : hasRemote ? 'local-missing' : 'neither'; } else { // File comparison with content if (hasLocal) { try { fileInfo.localContent = fs.readFileSync(localFilePath, 'utf-8'); } catch (err) { // Binary file or read error fileInfo.localContent = null; } } try { fileInfo.remoteContent = await this.ssh.exec(`cat ${remoteFilePath} 2>/dev/null`); if (hasLocal && fileInfo.localContent !== null && fileInfo.remoteContent !== null) { fileInfo.status = fileInfo.localContent.trim() === fileInfo.remoteContent.trim() ? 'match' : 'different'; } else if (hasLocal) { fileInfo.status = 'different'; // Local exists but can't compare content } else { fileInfo.status = 'local-missing'; } } catch (err) { fileInfo.status = hasLocal ? 'remote-missing' : 'neither'; } } diff.files.push(fileInfo); } this.ssh.disconnect(); } catch (err) { console.error('Failed to compare files:', err); throw err; } return diff; } async getRemoteFileContent(remotePath) { try { await this.ssh.connect(); const content = await this.ssh.exec(`cat ${remotePath} 2>/dev/null`); this.ssh.disconnect(); return content; } catch (err) { throw err; } } } module.exports = { ServerScanner };