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:
@@ -1,24 +1,81 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
// Shared config from gitea.repo.management
|
||||
const CONFIG_PATH = join(__dirname, '..', '..', '..', 'gitea.repo.management', 'config.json');
|
||||
// Shared Coolify config from gitea.repo.management
|
||||
const COOLIFY_CONFIG_PATH = join(PROJECT_ROOT, '..', 'gitea.repo.management', 'config.json');
|
||||
|
||||
let _cached = null;
|
||||
// Local deployment config
|
||||
const DEPLOY_CONFIG_PATH = join(PROJECT_ROOT, 'deployment-config.json');
|
||||
|
||||
// SSH credentials from app/.env
|
||||
const ENV_PATH = join(PROJECT_ROOT, 'app', '.env');
|
||||
|
||||
let _coolifyCache = null;
|
||||
let _deployCache = null;
|
||||
|
||||
export function loadConfig() {
|
||||
if (_cached) return _cached;
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Config not found at ${CONFIG_PATH}`);
|
||||
if (_coolifyCache) return _coolifyCache;
|
||||
if (!existsSync(COOLIFY_CONFIG_PATH)) {
|
||||
throw new Error(`Coolify config not found at ${COOLIFY_CONFIG_PATH}`);
|
||||
}
|
||||
_cached = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return _cached;
|
||||
_coolifyCache = JSON.parse(readFileSync(COOLIFY_CONFIG_PATH, 'utf8'));
|
||||
return _coolifyCache;
|
||||
}
|
||||
|
||||
export function reloadConfig() {
|
||||
_cached = null;
|
||||
_coolifyCache = null;
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
export function loadDeployConfig() {
|
||||
if (_deployCache) return _deployCache;
|
||||
if (!existsSync(DEPLOY_CONFIG_PATH)) {
|
||||
_deployCache = { servers: [], projectsRoot: 'C:\\.bucket\\repos.gitea', projects: {} };
|
||||
return _deployCache;
|
||||
}
|
||||
_deployCache = JSON.parse(readFileSync(DEPLOY_CONFIG_PATH, 'utf8'));
|
||||
return _deployCache;
|
||||
}
|
||||
|
||||
export function saveDeployConfig(config) {
|
||||
writeFileSync(DEPLOY_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
_deployCache = config;
|
||||
}
|
||||
|
||||
export function reloadDeployConfig() {
|
||||
_deployCache = null;
|
||||
return loadDeployConfig();
|
||||
}
|
||||
|
||||
export function loadEnv() {
|
||||
if (!existsSync(ENV_PATH)) return {};
|
||||
const content = readFileSync(ENV_PATH, 'utf8');
|
||||
const env = {};
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function getProjectsRoot() {
|
||||
return loadDeployConfig().projectsRoot || 'C:\\.bucket\\repos.gitea';
|
||||
}
|
||||
|
||||
export function getServerSshConfig(server) {
|
||||
const env = loadEnv();
|
||||
return {
|
||||
host: server.host,
|
||||
port: 22,
|
||||
username: env.SSH_USERNAME || server.username,
|
||||
password: env.SSH_PASSWORD || server.password,
|
||||
useSudo: server.useSudo || false,
|
||||
};
|
||||
}
|
||||
|
||||
153
api/lib/ssh.js
153
api/lib/ssh.js
@@ -1,9 +1,155 @@
|
||||
import { Client } from 'ssh2';
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
/**
|
||||
* Execute a command on the remote server via SSH.
|
||||
* Uses the ssh2 library (already a project dependency) instead of
|
||||
* the plink/sshpass fallback chain from gitea.repo.management.
|
||||
* SSHService — wraps ssh2 Client with exec + SFTP operations.
|
||||
* Ported from app/main/ssh-service.js to ESM for the API server.
|
||||
*/
|
||||
export 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) return reject(new Error('Not connected'));
|
||||
|
||||
this.client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0 && stderr) {
|
||||
reject(new Error(stderr.trim()));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('data', (data) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
uploadFile(localPath, remotePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('Not connected'));
|
||||
|
||||
this.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const readStream = createReadStream(localPath);
|
||||
const writeStream = sftp.createWriteStream(remotePath);
|
||||
|
||||
writeStream.on('close', () => resolve());
|
||||
writeStream.on('error', (e) => reject(e));
|
||||
readStream.on('error', (e) => reject(e));
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile(remotePath, localPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('Not connected'));
|
||||
|
||||
this.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const localDir = dirname(localPath);
|
||||
if (!existsSync(localDir)) mkdirSync(localDir, { recursive: true });
|
||||
|
||||
const readStream = sftp.createReadStream(remotePath);
|
||||
const writeStream = createWriteStream(localPath);
|
||||
|
||||
writeStream.on('close', () => resolve());
|
||||
writeStream.on('error', (e) => reject(e));
|
||||
readStream.on('error', (e) => reject(e));
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readRemoteFile(remotePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('Not connected'));
|
||||
|
||||
this.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
sftp.readFile(remotePath, 'utf-8', (e, data) => {
|
||||
if (e) reject(e); else resolve(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listDirectory(remotePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('Not connected'));
|
||||
|
||||
this.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
sftp.readdir(remotePath, (e, list) => {
|
||||
if (e) reject(e);
|
||||
else resolve(list.map(item => ({
|
||||
name: item.filename,
|
||||
isDirectory: item.attrs.isDirectory(),
|
||||
size: item.attrs.size,
|
||||
mtime: item.attrs.mtime,
|
||||
})));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a directory recursively via SFTP.
|
||||
*/
|
||||
async uploadDirectory(localDir, remoteDir) {
|
||||
await this.exec(`mkdir -p ${remoteDir}`);
|
||||
const entries = readdirSync(localDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const localPath = join(localDir, entry.name);
|
||||
const remotePath = `${remoteDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
await this.uploadDirectory(localPath, remotePath);
|
||||
} else {
|
||||
await this.uploadFile(localPath, remotePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot SSH exec (for Coolify operations that don't need persistent connection).
|
||||
*/
|
||||
export function sshExec(config, command) {
|
||||
const host = config.coolify?.sshHost;
|
||||
@@ -39,7 +185,6 @@ export function sshExec(config, command) {
|
||||
});
|
||||
|
||||
conn.on('error', (err) => reject(err));
|
||||
|
||||
conn.connect({ host, port: 22, username: user, password });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user