coolify integration.
This commit is contained in:
626
app/main/index.js
Normal file
626
app/main/index.js
Normal 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
123
app/main/project-scanner.js
Normal 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
280
app/main/server-scanner.js
Normal 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
196
app/main/ssh-service.js
Normal 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 };
|
||||
Reference in New Issue
Block a user