coolify integration.

This commit is contained in:
2026-02-27 08:55:41 -06:00
parent fe66be4aad
commit 2fe49b6725
62 changed files with 6366 additions and 129 deletions

626
app/main/index.js Normal file
View File

@@ -0,0 +1,626 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const { SSHService } = require('./ssh-service');
const { ProjectScanner } = require('./project-scanner');
const { ServerScanner } = require('./server-scanner');
let mainWindow;
let config = {
servers: [],
projectsRoot: 'C:\\.bucket\\repos.gitea',
projects: {}
};
const CONFIG_PATH = path.join(__dirname, '..', '..', 'deployment-config.json');
const ENV_PATH = path.join(__dirname, '..', '.env');
function loadConfig() {
try {
if (fs.existsSync(CONFIG_PATH)) {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
}
} catch (err) {
console.error('Failed to load config:', err);
}
}
function saveConfig() {
try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
} catch (err) {
console.error('Failed to save config:', err);
}
}
function loadEnv() {
try {
if (fs.existsSync(ENV_PATH)) {
const envContent = fs.readFileSync(ENV_PATH, 'utf-8');
const lines = envContent.split('\n');
const env = {};
for (const line of lines) {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
env[key.trim()] = valueParts.join('=').trim();
}
}
return env;
}
} catch (err) {
console.error('Failed to load .env:', err);
}
return {};
}
// Load project's docker-deployment.json config
function loadProjectDeployConfig(projectPath) {
const configPath = path.join(projectPath, 'docker-deployment.json');
try {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (err) {
console.error('Failed to load project config:', err);
}
return null;
}
// Get list of files to upload for a project
function getUploadFiles(projectPath, projectConfig) {
const projectName = path.basename(projectPath);
// Default files - always upload these if they exist
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' }
];
// Additional files from project config
const additionalFiles = projectConfig?.deployment?.uploadFiles || [];
const customFiles = additionalFiles.map(f => {
if (typeof f === 'string') {
// Simple string path - detect if directory
const isDir = f.endsWith('/');
return {
local: f.replace(/\/$/, ''),
remote: f.replace(/\/$/, ''),
type: isDir ? 'directory' : 'file'
};
}
return f; // Already an object with local/remote/type
});
return [...defaultFiles, ...customFiles];
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
title: 'Docker Deployment Manager'
});
if (process.argv.includes('--dev')) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'dist', 'index.html'));
}
}
app.whenReady().then(() => {
loadConfig();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC Handlers
// Get configuration
ipcMain.handle('get-config', () => {
return config;
});
// Save configuration
ipcMain.handle('save-config', (event, newConfig) => {
config = { ...config, ...newConfig };
saveConfig();
return { success: true };
});
// Get servers
ipcMain.handle('get-servers', () => {
return config.servers || [];
});
// Add/update server
ipcMain.handle('save-server', (event, server) => {
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);
}
saveConfig();
return { success: true, server };
});
// Delete server
ipcMain.handle('delete-server', (event, serverId) => {
config.servers = config.servers.filter(s => s.id !== serverId);
saveConfig();
return { success: true };
});
// Scan local projects (deep scan to find nested Dockerfiles)
ipcMain.handle('scan-local-projects', async () => {
const scanner = new ProjectScanner(config.projectsRoot);
const projects = await scanner.scanDeep(2);
return projects;
});
// Scan remote server for deployed containers
ipcMain.handle('scan-server', async (event, serverId) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
const scanner = new ServerScanner(sshConfig);
try {
const deployed = await scanner.scan();
return { success: true, deployed };
} catch (err) {
return { error: err.message };
}
});
// Compare local vs deployed
ipcMain.handle('compare-project', async (event, { projectPath, serverId, remotePath }) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
// Load project config to get additional files to compare
const projectConfig = loadProjectDeployConfig(projectPath);
const additionalFiles = projectConfig?.deployment?.uploadFiles || [];
const scanner = new ServerScanner(sshConfig);
try {
const diff = await scanner.compareFiles(projectPath, remotePath, additionalFiles);
return { success: true, diff };
} catch (err) {
return { error: err.message };
}
});
// Build tar for project
ipcMain.handle('build-tar', async (event, projectPath) => {
const { exec } = require('child_process');
const projectName = path.basename(projectPath);
return new Promise((resolve) => {
// Check if build-image-tar.ps1 exists
const scriptPath = path.join(projectPath, 'build-image-tar.ps1');
if (!fs.existsSync(scriptPath)) {
resolve({ error: 'No build-image-tar.ps1 found in project' });
return;
}
exec(`powershell -ExecutionPolicy Bypass -File "${scriptPath}"`, { cwd: projectPath }, (error, stdout, stderr) => {
if (error) {
resolve({ error: error.message, stderr });
} else {
resolve({ success: true, output: stdout });
}
});
});
});
// Deploy project to server
ipcMain.handle('deploy-project', async (event, { projectPath, serverId, remotePath }) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
const ssh = new SSHService(sshConfig);
const projectName = path.basename(projectPath);
// Sudo prefix for servers that need elevated permissions
const password = env.SSH_PASSWORD || server.password;
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
// Load project config and get files to upload
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 (may be owned by root)
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 = path.join(projectPath, fileSpec.local);
if (!fs.existsSync(localPath)) {
continue; // Skip if file doesn't exist locally
}
const remoteDest = `${remotePath}/${fileSpec.remote}`;
if (fileSpec.type === 'directory') {
// Create remote directory and upload all contents
await ssh.exec(`mkdir -p ${remoteDest}`);
// Get all files in directory recursively
const uploadDir = async (dirPath, remoteDir) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryLocalPath = path.join(dirPath, entry.name);
const entryRemotePath = `${remoteDir}/${entry.name}`;
if (entry.isDirectory()) {
await ssh.exec(`mkdir -p ${entryRemotePath}`);
await uploadDir(entryLocalPath, entryRemotePath);
} else {
await ssh.uploadFile(entryLocalPath, entryRemotePath);
}
}
};
await uploadDir(localPath, remoteDest);
uploadedFiles.push(`${fileSpec.local}/ (directory)`);
} else {
// Regular file
await ssh.uploadFile(localPath, remoteDest);
uploadedFiles.push(fileSpec.local);
}
}
// Load image, stop existing container, and start new container
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(resolve => setTimeout(resolve, 2000)); // Wait 2s
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 (err) {
// Ignore errors during health check polling
}
}
ssh.disconnect();
return {
success: true,
healthy,
status,
uploadedFiles,
message: healthy ? 'Container started successfully' : 'Container started but health check pending'
};
} catch (err) {
return { error: err.message };
}
});
// Get docker ps from server
ipcMain.handle('get-running-containers', async (event, serverId) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
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) {
return { error: err.message };
}
});
// Pull file from server (sync back)
ipcMain.handle('pull-file', async (event, { serverId, remotePath, localPath, isDirectory }) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
const ssh = new SSHService(sshConfig);
try {
await ssh.connect();
if (isDirectory) {
// Pull entire directory recursively
const pullDir = async (remoteDir, localDir) => {
// Ensure local directory exists
if (!fs.existsSync(localDir)) {
fs.mkdirSync(localDir, { recursive: true });
}
// List remote directory contents
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;
const remoteFilePath = `${remoteDir}/${fileName}`;
const localFilePath = path.join(localDir, fileName);
if (isDir) {
await pullDir(remoteFilePath, localFilePath);
} else {
await ssh.downloadFile(remoteFilePath, localFilePath);
}
}
};
await pullDir(remotePath, localPath);
} else {
// Pull single file
await ssh.downloadFile(remotePath, localPath);
}
ssh.disconnect();
return { success: true };
} catch (err) {
return { error: err.message };
}
});
// Pull multiple files from server
ipcMain.handle('pull-files', async (event, { serverId, files }) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
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 (same logic as above)
const pullDir = async (remoteDir, localDir) => {
if (!fs.existsSync(localDir)) {
fs.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;
const remoteFilePath = `${remoteDir}/${fileName}`;
const localFilePath = path.join(localDir, fileName);
if (isDir) {
await pullDir(remoteFilePath, localFilePath);
} else {
await ssh.downloadFile(remoteFilePath, localFilePath);
}
}
};
await pullDir(file.remotePath, file.localPath);
pulled.push(file.name);
} else {
// Ensure parent directory exists
const parentDir = path.dirname(file.localPath);
if (!fs.existsSync(parentDir)) {
fs.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) {
return { error: err.message };
}
});
// Initialize project with CLI tool
ipcMain.handle('init-project', async (event, projectPath) => {
const { exec } = require('child_process');
const cliPath = path.join(__dirname, '..', '..');
return new Promise((resolve) => {
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 || '') });
}
});
});
});
// Get container logs from server
ipcMain.handle('get-container-logs', async (event, { serverId, containerName, remotePath, lines = 100 }) => {
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
const ssh = new SSHService(sshConfig);
const password = env.SSH_PASSWORD || server.password;
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
try {
await ssh.connect();
// Use docker compose logs if remotePath is provided, otherwise docker logs
let logs;
if (remotePath) {
logs = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose logs --tail ${lines} 2>&1`);
} else {
logs = await ssh.exec(`${sudoPrefix}docker logs ${containerName} --tail ${lines} 2>&1`);
}
ssh.disconnect();
return { success: true, logs };
} catch (err) {
return { error: err.message };
}
});
// Open VS Code diff for file comparison
ipcMain.handle('open-vscode-diff', async (event, { localPath, remotePath, serverId, remoteFilePath }) => {
const { exec } = require('child_process');
const server = config.servers.find(s => s.id === serverId);
if (!server) {
return { error: 'Server not found' };
}
const env = loadEnv();
const sshConfig = {
host: server.host,
username: env.SSH_USERNAME || server.username,
password: env.SSH_PASSWORD || server.password
};
const ssh = new SSHService(sshConfig);
try {
// Download remote file to temp
const tempDir = path.join(require('os').tmpdir(), 'docker-deploy-diff');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFile = path.join(tempDir, `remote-${path.basename(localPath)}`);
await ssh.connect();
await ssh.downloadFile(remoteFilePath, tempFile);
ssh.disconnect();
// Open VS Code diff
return new Promise((resolve) => {
exec(`code --diff "${tempFile}" "${localPath}"`, (error) => {
if (error) {
resolve({ error: error.message });
} else {
resolve({ success: true });
}
});
});
} catch (err) {
return { error: err.message };
}
});

123
app/main/project-scanner.js Normal file
View File

@@ -0,0 +1,123 @@
const fs = require('fs');
const path = require('path');
class ProjectScanner {
constructor(rootPath) {
this.rootPath = rootPath;
}
async scan() {
const projects = [];
try {
const entries = fs.readdirSync(this.rootPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const projectPath = path.join(this.rootPath, entry.name);
const projectInfo = this.analyzeProject(projectPath, entry.name);
if (projectInfo) {
projects.push(projectInfo);
}
}
} catch (err) {
console.error('Failed to scan projects:', err);
}
return projects;
}
analyzeProject(projectPath, name) {
const hasDockerfile = fs.existsSync(path.join(projectPath, 'Dockerfile'));
const hasDockerCompose = fs.existsSync(path.join(projectPath, 'docker-compose.yml'));
const hasBuildScript = fs.existsSync(path.join(projectPath, 'build-image-tar.ps1'));
const hasDeployScript = fs.existsSync(path.join(projectPath, 'deploy-docker-auto.ps1'));
const hasDeploymentConfig = fs.existsSync(path.join(projectPath, 'docker-deployment.json'));
const hasDockerIgnore = fs.existsSync(path.join(projectPath, '.dockerignore'));
// Find tar files
let tarFile = null;
try {
const files = fs.readdirSync(projectPath);
tarFile = files.find(f => f.endsWith('.tar'));
} catch (err) {
// ignore
}
// Read deployment config if exists
let deploymentConfig = null;
if (hasDeploymentConfig) {
try {
deploymentConfig = JSON.parse(fs.readFileSync(path.join(projectPath, 'docker-deployment.json'), 'utf-8'));
} catch (err) {
// ignore
}
}
// Determine docker status
let dockerStatus = 'none';
if (hasDockerfile && hasDockerCompose) {
dockerStatus = 'configured';
} else if (hasDockerfile || hasDockerCompose) {
dockerStatus = 'partial';
}
return {
name,
path: projectPath,
hasDockerfile,
hasDockerCompose,
hasBuildScript,
hasDeployScript,
hasDeploymentConfig,
hasDockerIgnore,
tarFile,
deploymentConfig,
dockerStatus,
serverId: deploymentConfig?.deployment?.serverId || null,
remotePath: deploymentConfig?.deployment?.targetPath || `~/containers/${name}`
};
}
// Scan subdirectories too (for monorepos like AudioSphere)
async scanDeep(maxDepth = 2) {
const projects = [];
await this._scanRecursive(this.rootPath, projects, 0, maxDepth);
return projects;
}
async _scanRecursive(currentPath, projects, depth, maxDepth) {
if (depth > maxDepth) return;
try {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
// Check if this directory has a Dockerfile
const hasDockerfile = entries.some(e => e.name === 'Dockerfile');
if (hasDockerfile) {
const name = path.relative(this.rootPath, currentPath).replace(/\\/g, '/');
const projectInfo = this.analyzeProject(currentPath, name);
if (projectInfo) {
projects.push(projectInfo);
}
}
// Recurse into subdirectories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
if (entry.name === 'node_modules') continue;
if (entry.name === 'dist') continue;
if (entry.name === 'build') continue;
await this._scanRecursive(path.join(currentPath, entry.name), projects, depth + 1, maxDepth);
}
} catch (err) {
// ignore permission errors etc
}
}
}
module.exports = { ProjectScanner };

280
app/main/server-scanner.js Normal file
View File

@@ -0,0 +1,280 @@
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 };

196
app/main/ssh-service.js Normal file
View File

@@ -0,0 +1,196 @@
const { Client } = require('ssh2');
const fs = require('fs');
const path = require('path');
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) {
reject(new Error('Not connected'));
return;
}
this.client.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
let errorOutput = '';
stream.on('close', (code) => {
if (code !== 0 && errorOutput) {
reject(new Error(errorOutput));
} else {
resolve(output);
}
});
stream.on('data', (data) => {
output += data.toString();
});
stream.stderr.on('data', (data) => {
errorOutput += data.toString();
});
});
});
}
uploadFile(localPath, remotePath) {
return new Promise((resolve, reject) => {
if (!this.client) {
reject(new Error('Not connected'));
return;
}
this.client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
const readStream = fs.createReadStream(localPath);
const writeStream = sftp.createWriteStream(remotePath);
writeStream.on('close', () => {
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
readStream.pipe(writeStream);
});
});
}
downloadFile(remotePath, localPath) {
return new Promise((resolve, reject) => {
if (!this.client) {
reject(new Error('Not connected'));
return;
}
this.client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
// Ensure local directory exists
const localDir = path.dirname(localPath);
if (!fs.existsSync(localDir)) {
fs.mkdirSync(localDir, { recursive: true });
}
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
writeStream.on('close', () => {
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
readStream.on('error', (err) => {
reject(err);
});
readStream.pipe(writeStream);
});
});
}
readRemoteFile(remotePath) {
return new Promise((resolve, reject) => {
if (!this.client) {
reject(new Error('Not connected'));
return;
}
this.client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
sftp.readFile(remotePath, 'utf-8', (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
});
}
listDirectory(remotePath) {
return new Promise((resolve, reject) => {
if (!this.client) {
reject(new Error('Not connected'));
return;
}
this.client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
sftp.readdir(remotePath, (err, list) => {
if (err) {
reject(err);
} else {
resolve(list.map(item => ({
name: item.filename,
isDirectory: item.attrs.isDirectory(),
size: item.attrs.size,
mtime: item.attrs.mtime
})));
}
});
});
});
}
}
module.exports = { SSHService };