fix: sshCmd closure bug + error display

CRITICAL BUG: sshConnectCmd wrapped tea.ExecProcess inside an extra
closure, so Bubble Tea never executed the SSH process — Enter appeared
to do nothing.

Fix:
- sshConnectCmd now returns tea.ExecProcess directly (no extra closure)
- tea.ExecProcess handles all process lifecycle (start, PTY, exit)
- sshExitMsg errors are shown in host list view (ErrorStyle)
- Error routed via Model.Error → HostListTab.err → View()
This commit is contained in:
swanadiva
2026-06-23 15:50:20 +07:00
parent 79f3917a66
commit daa20ac72b
3 changed files with 104 additions and 80 deletions
+7
View File
@@ -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))
+86 -80
View File
@@ -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 <tmpfile> 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}
}
}
+11
View File
@@ -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()
}