using System.Diagnostics; using System.Windows; using AxCopilot.Models; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다. /// /// 예: ssh → 저장된 SSH 호스트 목록 /// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터 /// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22) /// ssh add name user@host:22 → 이름 지정하여 추가 /// ssh del <이름> → 호스트 삭제 /// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결. /// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도). /// public class SshHandler : IActionHandler { private readonly SettingsService _settings; public SshHandler(SettingsService settings) { _settings = settings; } public string? Prefix => "ssh"; public PluginMetadata Metadata => new( "SSH", "SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결", "1.0", "AX"); public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); var hosts = _settings.Settings.SshHosts; if (string.IsNullOrWhiteSpace(q)) { if (hosts.Count == 0) { items.Add(new LauncherItem( "SSH 호스트 없음", "ssh add user@host 또는 ssh add 이름 user@host:22", null, null, Symbol: "\uE968")); } else { foreach (var h in hosts) items.Add(MakeHostItem(h)); } items.Add(new LauncherItem( "ssh add user@host", "새 호스트 추가", null, null, Symbol: "\uE710")); return Task.FromResult>(items); } var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); var sub = parts[0].ToLowerInvariant(); // ── add ───────────────────────────────────────────────────────── if (sub == "add") { SshHostEntry? entry = null; if (parts.Length == 2) { // ssh add user@host[:port] entry = ParseUserHost(parts[1]); if (entry != null) entry.Name = entry.Host; } else if (parts.Length == 3) { // ssh add user@host[:port] entry = ParseUserHost(parts[2]); if (entry != null) entry.Name = parts[1]; } if (entry != null) { items.Add(new LauncherItem( $"추가: {entry.Name}", $"{entry.User}@{entry.Host}:{entry.Port}", null, ("add", entry), Symbol: "\uE710")); } else { items.Add(new LauncherItem( "형식: ssh add user@host[:port]", "또는: ssh add 이름 user@host:22", null, null, Symbol: "\uE783")); } return Task.FromResult>(items); } // ── del ───────────────────────────────────────────────────────── if (sub == "del" || sub == "delete" || sub == "rm") { var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : ""; var toDelete = hosts .Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase) || h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)) .ToList(); if (toDelete.Count == 0) { items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783")); } else { foreach (var h in toDelete) items.Add(new LauncherItem( $"삭제: {h.Name}", $"{h.User}@{h.Host}:{h.Port}", null, ("del", h.Id), Symbol: "\uE74D")); } return Task.FromResult>(items); } // ── 검색 (이름 / 호스트 / 사용자 / 메모) ───────────────────────── var filtered = hosts.Where(h => h.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || h.Host.Contains(q, StringComparison.OrdinalIgnoreCase) || h.User.Contains(q, StringComparison.OrdinalIgnoreCase) || h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); if (filtered.Count > 0) { foreach (var h in filtered) items.Add(MakeHostItem(h)); } else { // 직접 접속 시도 (user@host 형식) var entry = ParseUserHost(q); if (entry != null) { items.Add(new LauncherItem( $"연결: {entry.User}@{entry.Host}:{entry.Port}", "Enter → Windows Terminal로 연결", null, ("connect", entry), Symbol: "\uE968")); items.Add(new LauncherItem( $"저장 후 연결", $"이름: {entry.Host}", null, ("add_connect", entry), Symbol: "\uE710")); } else { items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783")); } } return Task.FromResult>(items); } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { switch (item.Data) { case ("connect", SshHostEntry h): ConnectSsh(h); break; case ("add", SshHostEntry h): _settings.Settings.SshHosts.RemoveAll(e => e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase) && e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase) && e.Port == h.Port); _settings.Settings.SshHosts.Add(h); _settings.Save(); NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다."); break; case ("add_connect", SshHostEntry h): if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host; _settings.Settings.SshHosts.Add(h); _settings.Save(); ConnectSsh(h); break; case ("del", string id): var removed = _settings.Settings.SshHosts .RemoveAll(e => e.Id == id); if (removed > 0) { _settings.Save(); NotificationService.Notify("SSH", "호스트를 삭제했습니다."); } break; // 호스트 항목 직접 Enter case SshHostEntry host: ConnectSsh(host); break; } return Task.CompletedTask; } // ── 헬퍼 ──────────────────────────────────────────────────────────────── private static LauncherItem MakeHostItem(SshHostEntry h) { var portStr = h.Port != 22 ? $":{h.Port}" : ""; return new LauncherItem( h.Name, $"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}", null, h, Symbol: "\uE968"); } /// Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다. private static void ConnectSsh(SshHostEntry h) { try { // Windows Terminal (wt.exe) 우선 var wtPath = FindExecutable("wt.exe"); if (!string.IsNullOrEmpty(wtPath)) { var portArgs = h.Port != 22 ? $" -p {h.Port}" : ""; var userHost = string.IsNullOrEmpty(h.User) ? h.Host : $"{h.User}@{h.Host}"; Process.Start(new ProcessStartInfo { FileName = wtPath, Arguments = $"ssh {userHost}{portArgs}", UseShellExecute = true, }); return; } // PuTTY 대체 var puttyPath = FindExecutable("putty.exe"); if (!string.IsNullOrEmpty(puttyPath)) { var userHost = string.IsNullOrEmpty(h.User) ? h.Host : $"{h.User}@{h.Host}"; Process.Start(new ProcessStartInfo { FileName = puttyPath, Arguments = $"-ssh {userHost} -P {h.Port}", UseShellExecute = true, }); return; } // PowerShell 폴백 var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : ""; var cmd = string.IsNullOrEmpty(h.User) ? $"ssh {h.Host}{portArgs2}" : $"ssh {h.User}@{h.Host}{portArgs2}"; Process.Start(new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"-NoExit -Command \"{cmd}\"", UseShellExecute = true, }); } catch (Exception ex) { NotificationService.Notify("SSH 오류", ex.Message); } } /// PATH 및 일반 설치 경로에서 실행 파일을 찾습니다. private static string? FindExecutable(string exe) { // PATH 검색 var envPath = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries)) { var full = System.IO.Path.Combine(dir.Trim(), exe); if (System.IO.File.Exists(full)) return full; } // 일반 설치 경로 var candidates = new[] { System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft\\WindowsApps", exe), System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe), System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe), }; return candidates.FirstOrDefault(System.IO.File.Exists); } /// user@host:port 형식을 파싱합니다. private static SshHostEntry? ParseUserHost(string s) { if (string.IsNullOrEmpty(s)) return null; string user = ""; string host = s; int port = 22; // user@host 파싱 var atIdx = s.IndexOf('@'); if (atIdx >= 0) { user = s[..atIdx]; host = s[(atIdx + 1)..]; } // host:port 파싱 var colonIdx = host.LastIndexOf(':'); if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p)) { port = p; host = host[..colonIdx]; } if (string.IsNullOrEmpty(host)) return null; return new SshHostEntry { Id = Guid.NewGuid().ToString(), Name = host, Host = host, Port = port, User = user, }; } }