diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index 209eb1e..1533416 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -121,6 +121,13 @@ func (t *HostListTab) View() string { var b strings.Builder + // Show error if any + if t.err != nil { + b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err))) + b.WriteString("\n") + t.err = nil + } + // Capped vertical padding b.WriteString(strings.Repeat("\n", 2)) diff --git a/pkg/tui/ssh.go b/pkg/tui/ssh.go index 717ee2e..79099ad 100644 --- a/pkg/tui/ssh.go +++ b/pkg/tui/ssh.go @@ -16,101 +16,107 @@ import ( // sshConnectCmd builds and runs a native SSH command via tea.ExecProcess. // Password auth: sshpass -e ssh user@host (SSHPASS env) // Key auth: ssh -i user@host (SSH_ASKPASS for passphrase) +// +// Must return tea.ExecProcess directly (NOT wrapped in another closure) +// so Bubble Tea can execute the process command correctly. func sshConnectCmd(host *models.Host, dataDir string) tea.Cmd { - return func() tea.Msg { - port := host.Port - if port == 0 { - port = 22 + port := host.Port + if port == 0 { + port = 22 + } + portStr := strconv.Itoa(port) + target := fmt.Sprintf("%s@%s", host.Username, host.Hostname) + ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID) + env := os.Environ() + + // Common SSH args + sshArgs := []string{ + "-p", portStr, + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ServerAliveInterval=60", + "-o", "ServerAliveCountMax=3", + "-S", ctrlSock, + "-o", "ControlMaster=auto", + } + + cleanup := func() { + exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run() + } + + switch host.Auth.Type { + case "password": + allArgs := append([]string{"-e", "ssh"}, sshArgs...) + allArgs = append(allArgs, target) + cmd := exec.Command("sshpass", allArgs...) + cmd.Env = append(env, "SSHPASS="+host.Auth.Password) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + cleanup() + return sshExitMsg{err: err} + }) + + case "key": + keyContent, err := loadKeyContent(host, dataDir) + if err != nil { + return errorCmd(fmt.Errorf("load key: %w", err)) } - portStr := strconv.Itoa(port) - target := fmt.Sprintf("%s@%s", host.Username, host.Hostname) - - var cmd *exec.Cmd - env := os.Environ() - - ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID) - - switch host.Auth.Type { - case "password": - args := []string{ - "-e", - "ssh", - "-p", portStr, - "-o", "StrictHostKeyChecking=accept-new", - "-o", "ServerAliveInterval=60", - "-o", "ServerAliveCountMax=3", - "-S", ctrlSock, - "-o", "ControlMaster=auto", - target, - } - cmd = exec.Command("sshpass", args...) - env = append(env, "SSHPASS="+host.Auth.Password) - - case "key": - keyContent, err := loadKeyContent(host, dataDir) - if err != nil { - return sshExitMsg{err: err} - } - tmpFile, err := os.CreateTemp("", "hk-key-*") - if err != nil { - return sshExitMsg{err: fmt.Errorf("create temp key: %w", err)} - } - tmpPath := tmpFile.Name() - if _, err := tmpFile.Write([]byte(keyContent)); err != nil { - tmpFile.Close() - os.Remove(tmpPath) - return sshExitMsg{err: fmt.Errorf("write temp key: %w", err)} - } + tmpFile, err := os.CreateTemp("", "hk-key-*") + if err != nil { + return errorCmd(fmt.Errorf("create temp key: %w", err)) + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write([]byte(keyContent)); err != nil { tmpFile.Close() - os.Chmod(tmpPath, 0600) + os.Remove(tmpPath) + return errorCmd(fmt.Errorf("write temp key: %w", err)) + } + tmpFile.Close() + os.Chmod(tmpPath, 0600) - args := []string{ - "-i", tmpPath, - "-p", portStr, - "-o", "StrictHostKeyChecking=accept-new", - "-o", "ServerAliveInterval=60", - "-o", "ServerAliveCountMax=3", - "-S", ctrlSock, - "-o", "ControlMaster=auto", - target, - } - cmd = exec.Command("ssh", args...) + keyArgs := append([]string{"-i", tmpPath}, sshArgs...) + keyArgs = append(keyArgs, target) + cmd := exec.Command("ssh", keyArgs...) - if host.Auth.Password != "" { - self, err := os.Executable() + if host.Auth.Password != "" { + self, err := os.Executable() + if err == nil { + script := fmt.Sprintf("#!/bin/sh\nexec %q askpass\n", self) + f, err := os.CreateTemp("", "hk-askpass-*.sh") if err == nil { - script := fmt.Sprintf("#!/bin/sh\nexec %q askpass\n", self) - f, err := os.CreateTemp("", "hk-askpass-*.sh") - if err == nil { - f.WriteString(script) - f.Close() - os.Chmod(f.Name(), 0700) - env = append(env, - "HK_PASSPHRASE="+host.Auth.Password, - "SSH_ASKPASS="+f.Name(), - "SSH_ASKPASS_REQUIRE=force", - ) - if os.Getenv("DISPLAY") == "" { - env = append(env, "DISPLAY=:0") - } - if setsid, err := exec.LookPath("setsid"); err == nil { - newArgs := append([]string{"ssh"}, args...) - cmd = exec.Command(setsid, newArgs...) - } + f.WriteString(script) + f.Close() + os.Chmod(f.Name(), 0700) + env = append(env, + "HK_PASSPHRASE="+host.Auth.Password, + "SSH_ASKPASS="+f.Name(), + "SSH_ASKPASS_REQUIRE=force", + ) + if os.Getenv("DISPLAY") == "" { + env = append(env, "DISPLAY=:0") + } + if setsid, err := exec.LookPath("setsid"); err == nil { + newArgs := append([]string{"ssh"}, keyArgs...) + cmd = exec.Command(setsid, newArgs...) } } } - - default: - return sshExitMsg{err: fmt.Errorf("unsupported auth type: %s", host.Auth.Type)} } cmd.Env = env return tea.ExecProcess(cmd, func(err error) tea.Msg { - // Cleanup ControlMaster socket - exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run() + os.Remove(tmpPath) + cleanup() return sshExitMsg{err: err} }) + + default: + return errorCmd(fmt.Errorf("unsupported auth type: %s", host.Auth.Type)) + } +} + +// errorCmd returns a Cmd that sends an sshExitMsg with the given error. +func errorCmd(err error) tea.Cmd { + return func() tea.Msg { + return sshExitMsg{err: err} } } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 3ac3bb6..0d62ca6 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -65,6 +65,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, sshConnectCmd(msg.host, m.dataDir) case sshExitMsg: + if msg.err != nil { + m.Error = msg.err + } return m, nil case openHostFormMsg: @@ -203,6 +206,14 @@ func (m *Model) View() string { return "No tabs open. Press 'q' to quit.\n" } + // Pass error to host list tab for display + if m.Error != nil { + if ht := FindHostListTab(m.tabs.tabs); ht != nil { + ht.err = m.Error + } + m.Error = nil + } + return m.tabs.View() }