Add full REST API for all deployment operations (projects, servers, docker)

Port all IPC handlers to HTTP endpoints so the UI and LLM use the same
API. Adds routes/projects.js (scan, compare, init), routes/servers.js
(CRUD, containers, logs), routes/docker.js (build, deploy, pull, vscode-diff).
Enhanced ssh.js with full SSHService class (SFTP upload/download).
Updated renderer api.js to use fetch instead of window.api IPC.
Added concurrently for npm run dev (API + Vite + Electron).
OpenAPI spec now covers all 24 endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 11:17:40 -06:00
parent 93d40455d9
commit feec35ffce
9 changed files with 1329 additions and 153 deletions

153
api/routes/servers.js Normal file
View File

@@ -0,0 +1,153 @@
import { Router } from 'express';
import { loadDeployConfig, saveDeployConfig, 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 });
}
};
}
// GET /api/servers — list all configured deployment servers
router.get('/', wrap(async () => {
const config = loadDeployConfig();
return config.servers || [];
}));
// POST /api/servers — add or update a server
router.post('/', wrap(async (req) => {
const server = req.body;
const config = loadDeployConfig();
if (!config.servers) config.servers = [];
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);
}
saveDeployConfig(config);
return { success: true, server };
}));
// DELETE /api/servers/:id — delete a server
router.delete('/:id', wrap(async (req) => {
const config = loadDeployConfig();
config.servers = (config.servers || []).filter(s => s.id !== req.params.id);
saveDeployConfig(config);
return { success: true };
}));
// GET /api/servers/:id/scan — scan remote server for deployed containers
router.get('/:id/scan', wrap(async (req) => {
const config = loadDeployConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
const sshConfig = getServerSshConfig(server);
const ssh = new SSHService(sshConfig);
try {
await ssh.connect();
const result = await ssh.exec('ls -1 ~/containers 2>/dev/null || echo ""');
const projectDirs = result.split('\n').filter(Boolean);
const deployed = [];
for (const dir of projectDirs) {
const remotePath = `~/containers/${dir}`;
try {
const filesResult = await 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)/);
let dockerComposeContent = null;
if (hasDockerCompose) {
try { dockerComposeContent = await ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); } catch { /* ignore */ }
}
deployed.push({
name: dir,
remotePath,
hasDockerCompose,
hasEnv,
hasData,
tarFile: tarMatch ? tarMatch[1] : null,
dockerComposeContent,
});
} catch { /* skip */ }
}
ssh.disconnect();
return { success: true, deployed };
} catch (err) {
throw new Error(`Scan failed: ${err.message}`);
}
}));
// GET /api/servers/:id/containers — list running docker containers
router.get('/:id/containers', wrap(async (req) => {
const config = loadDeployConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
const sshConfig = getServerSshConfig(server);
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) {
throw new Error(`Failed to get containers: ${err.message}`);
}
}));
// GET /api/servers/:id/logs — get container logs
router.get('/:id/logs', wrap(async (req) => {
const config = loadDeployConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) throw Object.assign(new Error('Server not found'), { status: 404 });
const { containerName, remotePath, lines = 100 } = req.query;
const sshConfig = getServerSshConfig(server);
const ssh = new SSHService(sshConfig);
const password = sshConfig.password;
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
try {
await ssh.connect();
let logs;
if (remotePath) {
logs = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose logs --tail ${lines} 2>&1`);
} else if (containerName) {
logs = await ssh.exec(`${sudoPrefix}docker logs ${containerName} --tail ${lines} 2>&1`);
} else {
throw Object.assign(new Error('containerName or remotePath required'), { status: 400 });
}
ssh.disconnect();
return { success: true, logs };
} catch (err) {
throw new Error(`Failed to get logs: ${err.message}`);
}
}));
export default router;