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