Files
idea.llm.gitea.repo.docker.…/app/main/index.js
2026-02-27 08:55:41 -06:00

627 lines
18 KiB
JavaScript

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 };
}
});