import { Client, ClientChannel, SFTPWrapper } from 'ssh2';
import { OutputBuffer } from './output-buffer.js';
import { SSHConfig, ExecResult } from '../types/index.js';
export class SSHClient {
private conn: Client | null = null;
private outputBuffer: OutputBuffer;
private config: SSHConfig;
public readonly sessionId: string;
public lastActivity: Date = new Date();
constructor(sessionId: string, config: SSHConfig, bufferSize = 1000) {
this.sessionId = sessionId;
this.config = config;
this.outputBuffer = new OutputBuffer(bufferSize);
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.conn = new Client();
const timeout = setTimeout(() => {
this.conn?.end();
reject(new Error('Connection timeout'));
}, this.config.readyTimeout || 30000);
this.conn.on('ready', () => {
clearTimeout(timeout);
this.touch();
resolve();
});
this.conn.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
this.conn.connect({
host: this.config.host,
port: this.config.port,
username: this.config.username,
password: this.config.password,
privateKey: this.config.privateKey,
readyTimeout: this.config.readyTimeout || 30000,
keepaliveInterval: this.config.keepaliveInterval || 10000,
});
});
}
private touch(): void {
this.lastActivity = new Date();
}
isConnected(): boolean {
return this.conn !== null && (this.conn as any)._sock?.writable === true;
}
async exec(command: string, timeoutMs = 30000): Promise<ExecResult> {
this.touch();
return new Promise((resolve, reject) => {
if (!this.conn || !this.isConnected()) {
resolve({ stdout: '', stderr: '', exitCode: 1, status: 'error' });
return;
}
let stdout = '';
let stderr = '';
let exitCode = 0;
const timeout = setTimeout(() => {
resolve({ stdout, stderr, exitCode: -1, status: 'timeout' });
}, timeoutMs);
this.conn.exec(command, (err, stream: ClientChannel) => {
if (err) {
clearTimeout(timeout);
resolve({ stdout: '', stderr: err.message, exitCode: 1, status: 'error' });
return;
}
stream.on('close', (code: number) => {
clearTimeout(timeout);
exitCode = code ?? 0;
this.touch();
resolve({ stdout, stderr, exitCode, status: 'completed' });
});
stream.on('data', (data: Buffer) => {
const output = data.toString();
stdout += output;
this.outputBuffer.append(output);
});
stream.stderr.on('data', (data: Buffer) => {
const output = data.toString();
stderr += output;
this.outputBuffer.append(output);
});
});
});
}
async execBackground(command: string): Promise<{ pid: string }> {
this.touch();
const result = await this.exec(`nohup ${command} > /dev/null 2>&1 & echo $!`, 5000);
return { pid: result.stdout.trim() };
}
getBuffer(): OutputBuffer {
return this.outputBuffer;
}
async getSftp(): Promise<SFTPWrapper> {
this.touch();
return new Promise((resolve, reject) => {
if (!this.conn || !this.isConnected()) {
reject(new Error('Not connected'));
return;
}
this.conn.sftp((err, sftp) => {
if (err) reject(err);
else resolve(sftp);
});
});
}
close(): void {
if (this.conn) {
this.conn.end();
this.conn = null;
}
}
}