Files
idea.llm.gitea.repo.docker.…/api/lib/ssh.js
Clint Masden feec35ffce 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>
2026-02-27 11:17:40 -06:00

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