OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-lab / lib / ssh-manager.js

132 lines · javascript ref:
import { Client } from 'ssh2'
import fs from 'fs'
import chalk from 'chalk'

export class SSHManager {
  constructor(agentConfig) {
    this.config = agentConfig
    this.client = null
    this.connected = false
  }

  async connect() {
    if (this.connected) return

    // Special case: localhost doesn't need SSH
    if (this.config.host === 'localhost' || this.config.host === '127.0.0.1') {
      this.connected = true
      this.isLocalhost = true
      console.log(chalk.green(`✅ Connected to ${this.config.name} (localhost)`))
      return
    }

    this.client = new Client()
    this.isLocalhost = false

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Connection timeout for ${this.config.name}`))
      }, 10000)

      this.client.on('ready', () => {
        clearTimeout(timeout)
        this.connected = true
        console.log(chalk.green(`✅ Connected to ${this.config.name}`))
        resolve()
      })

      this.client.on('error', (err) => {
        clearTimeout(timeout)
        reject(new Error(`Connection failed for ${this.config.name}: ${err.message}`))
      })

      // Try to use SSH agent first (for encrypted keys)
      const connectOptions = {
        host: this.config.host,
        username: this.config.user,
        agent: process.env.SSH_AUTH_SOCK, // Use ssh-agent
        agentForward: true
      }

      // If agent not available, try reading key directly
      if (!connectOptions.agent) {
        connectOptions.privateKey = fs.readFileSync(this.config.keyFile)
      }

      this.client.connect(connectOptions)
    })
  }

  async exec(command, options = {}) {
    if (!this.connected) {
      throw new Error('Not connected')
    }

    // Special case: localhost uses child_process
    if (this.isLocalhost) {
      const { exec } = await import('child_process')
      const { promisify } = await import('util')
      const execAsync = promisify(exec)

      try {
        const result = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 })

        if (options.logOutput) {
          console.log(chalk.cyan(`[${this.config.name}] ${result.stdout.trim()}`))
        }

        return {
          stdout: result.stdout,
          stderr: result.stderr,
          exitCode: 0
        }
      } catch (err) {
        if (options.logOutput) {
          console.error(chalk.red(`[${this.config.name}] ${err.stderr || err.message}`))
        }

        return {
          stdout: err.stdout || '',
          stderr: err.stderr || err.message,
          exitCode: err.code || 1
        }
      }
    }

    // SSH execution
    return new Promise((resolve, reject) => {
      this.client.exec(command, (err, stream) => {
        if (err) return reject(err)

        let stdout = ''
        let stderr = ''

        stream.on('data', (data) => {
          stdout += data.toString()
          if (options.logOutput) {
            console.log(chalk.cyan(`[${this.config.name}] ${data.toString().trim()}`))
          }
        })

        stream.stderr.on('data', (data) => {
          stderr += data.toString()
          if (options.logOutput) {
            console.error(chalk.red(`[${this.config.name}] ${data.toString().trim()}`))
          }
        })

        stream.on('close', (code) => {
          resolve({ stdout, stderr, exitCode: code })
        })
      })
    })
  }

  async disconnect() {
    if (this.client) {
      this.client.end()
      this.connected = false
    }
  }
}