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>
191 lines
5.3 KiB
JavaScript
191 lines
5.3 KiB
JavaScript
import { Client } from 'ssh2';
|
|
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
|
|
/**
|
|
* 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;
|
|
const user = config.coolify?.sshUser;
|
|
const password = config.coolify?.sshPassword;
|
|
|
|
if (!host || !user) {
|
|
return Promise.reject(new Error('SSH not configured — set coolify.sshHost and coolify.sshUser in config.json'));
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new Client();
|
|
|
|
conn.on('ready', () => {
|
|
conn.exec(command, (err, stream) => {
|
|
if (err) { conn.end(); return reject(err); }
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
stream.on('close', (code) => {
|
|
conn.end();
|
|
if (code !== 0 && stderr) {
|
|
reject(new Error(stderr.trim()));
|
|
} else {
|
|
resolve(stdout.trim());
|
|
}
|
|
});
|
|
|
|
stream.on('data', (data) => { stdout += data.toString(); });
|
|
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
});
|
|
});
|
|
|
|
conn.on('error', (err) => reject(err));
|
|
conn.connect({ host, port: 22, username: user, password });
|
|
});
|
|
}
|