From 86e5bb5f0cc7d1729510cba0d9e4d45a64c4baae Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 6 Jun 2026 03:26:17 +0800 Subject: [PATCH] feat: replace tui action stubs --- cmd/extra.go | 76 ++++++++++++++-------- cmd/tui.go | 37 +++++++++++ cmd/vault.go | 72 +++++++++++---------- internal/tui/app.go | 63 +++++++++++++++--- internal/tui/app_test.go | 135 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 70 deletions(-) diff --git a/cmd/extra.go b/cmd/extra.go index f59fc8d..55278ff 100644 --- a/cmd/extra.go +++ b/cmd/extra.go @@ -13,31 +13,12 @@ var importCmd = &cobra.Command{ Use: "import", Short: "Import servers from ~/.ssh/config", RunE: func(cmd *cobra.Command, args []string) error { - servers, err := ssh.ImportFromSSHConfig() + imported, err := importServersFromSSHConfig(func(format string, args ...interface{}) { + fmt.Printf(format+"\n", args...) + }) if err != nil { - return fmt.Errorf("import: %w", err) + return err } - - if len(servers) == 0 { - fmt.Println("No servers found in ~/.ssh/config") - return nil - } - - imported := 0 - for _, s := range servers { - existing, _ := appDB.GetServer(s.Alias) - if existing != nil { - fmt.Printf(" skip (exists): %s\n", s.Alias) - continue - } - if err := appDB.CreateServer(s); err != nil { - fmt.Printf(" error: %s: %v\n", s.Alias, err) - continue - } - fmt.Printf(" imported: %s (%s@%s:%d)\n", s.Alias, s.User, s.Host, s.Port) - imported++ - } - fmt.Printf("\nImported %d servers.\n", imported) return nil }, @@ -52,13 +33,56 @@ var exportCmd = &cobra.Command{ return fmt.Errorf("list servers: %w", err) } - for _, s := range servers { - fmt.Printf("%s\t%s@%s:%d\t%s\n", s.Alias, s.User, s.Host, s.Port, s.AuthMethod) - } + fmt.Print(formatServersExport(servers)) return nil }, } +func importServersFromSSHConfig(report func(format string, args ...interface{})) (int, error) { + servers, err := ssh.ImportFromSSHConfig() + if err != nil { + return 0, fmt.Errorf("import: %w", err) + } + + if len(servers) == 0 { + if report != nil { + report("No servers found in ~/.ssh/config") + } + return 0, nil + } + + imported := 0 + for _, s := range servers { + existing, _ := appDB.GetServer(s.Alias) + if existing != nil { + if report != nil { + report(" skip (exists): %s", s.Alias) + } + continue + } + if err := appDB.CreateServer(s); err != nil { + if report != nil { + report(" error: %s: %v", s.Alias, err) + } + continue + } + if report != nil { + report(" imported: %s (%s@%s:%d)", s.Alias, s.User, s.Host, s.Port) + } + imported++ + } + + return imported, nil +} + +func formatServersExport(servers []*model.Server) string { + var b strings.Builder + for _, s := range servers { + fmt.Fprintf(&b, "%s\t%s@%s:%d\t%s\n", s.Alias, s.User, s.Host, s.Port, s.AuthMethod) + } + return b.String() +} + var runCmd = &cobra.Command{ Use: "run ", Short: "Run a command on a server", diff --git a/cmd/tui.go b/cmd/tui.go index a35f89b..83ae297 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -138,6 +138,14 @@ func runTUI() error { tui.DeleteForward = func(forwardID int64) error { return appDB.DeleteForward(forwardID) } + tui.ImportServers = func() (int, error) { + return importServersFromSSHConfig(nil) + } + tui.LockVault = func() error { + v := getOrCreateVault() + v.Lock() + return nil + } tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error { return appDB.UpdateTestResult(alias, status, testErr) } @@ -213,6 +221,35 @@ func runTUI() error { continue } + if result != nil && result.Action == "export" { + servers, err := appDB.ListServers() + if err != nil { + fmt.Fprintf(os.Stderr, "Export error: %v\n", err) + } else { + fmt.Print(formatServersExport(servers)) + } + + fmt.Println("\n[Press Enter to return to sshkeeper]") + buf := make([]byte, 1) + os.Stdin.Read(buf) + + servers, _ = appDB.ListServers() + continue + } + + if result != nil && result.Action == "vault_change_pw" { + if err := changeVaultPasswordInteractive(); err != nil { + fmt.Fprintf(os.Stderr, "Vault password change error: %v\n", err) + } + + fmt.Println("\n[Press Enter to return to sshkeeper]") + buf := make([]byte, 1) + os.Stdin.Read(buf) + + servers, _ = appDB.ListServers() + continue + } + if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n" || result.Action == "tunnel_bg") && result.Server != nil { server := result.Server fresh, err := appDB.GetServer(server.Alias) diff --git a/cmd/vault.go b/cmd/vault.go index bf6e9fe..499e774 100644 --- a/cmd/vault.go +++ b/cmd/vault.go @@ -118,40 +118,7 @@ var vaultChangePasswordCmd = &cobra.Command{ Use: "change-password", Short: "Change master password", RunE: func(cmd *cobra.Command, args []string) error { - v := getOrCreateVault() - - if err := unlockVaultForCommand(v); err != nil { - return err - } - - fmt.Print("New master password: ") - pw1, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() - if err != nil { - return fmt.Errorf("read password: %w", err) - } - - if len(pw1) == 0 { - return fmt.Errorf("password cannot be empty") - } - - fmt.Print("Repeat new master password: ") - pw2, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() - if err != nil { - return fmt.Errorf("read password: %w", err) - } - - if string(pw1) != string(pw2) { - return fmt.Errorf("passwords do not match") - } - - if err := v.ChangePassword(string(pw1)); err != nil { - return fmt.Errorf("change password: %w", err) - } - - fmt.Println("Master password changed.") - return nil + return changeVaultPasswordInteractive() }, } @@ -220,6 +187,43 @@ func unlockVaultForCommand(v *vault.Vault) error { return nil } +func changeVaultPasswordInteractive() error { + v := getOrCreateVault() + + if err := unlockVaultForCommand(v); err != nil { + return err + } + + fmt.Print("New master password: ") + pw1, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("read password: %w", err) + } + + if len(pw1) == 0 { + return fmt.Errorf("password cannot be empty") + } + + fmt.Print("Repeat new master password: ") + pw2, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("read password: %w", err) + } + + if string(pw1) != string(pw2) { + return fmt.Errorf("passwords do not match") + } + + if err := v.ChangePassword(string(pw1)); err != nil { + return fmt.Errorf("change password: %w", err) + } + + fmt.Println("Master password changed.") + return nil +} + func vaultLockedProcessMessage() string { return "vault is locked in this process; enter the master password when this command prompts for it" } diff --git a/internal/tui/app.go b/internal/tui/app.go index e4cbf13..7ca6964 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -94,6 +94,12 @@ type forwardDeletedMsg struct { err error } +type importDoneMsg struct { + servers []*model.Server + count int + err error +} + // --- List items --- type serverItem struct { @@ -171,6 +177,8 @@ var ( SaveForward func(fwd *model.Forward) error UpdateForward func(fwd *model.Forward) error DeleteForward func(forwardID int64) error + ImportServers func() (int, error) + LockVault func() error ) // --- Screen type --- @@ -240,6 +248,7 @@ type tuiModel struct { confirmAction func() tea.Cmd fullHelp *fullHelpModel } + func New(servers []*model.Server) *tuiModel { items := make([]list.Item, len(servers)) for i, s := range servers { @@ -354,6 +363,20 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingTemplate = nil return m, nil + case importDoneMsg: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.servers = msg.servers + items := make([]list.Item, len(msg.servers)) + for i, s := range msg.servers { + items[i] = serverItem{server: s} + } + m.list.SetItems(items) + m.success = fmt.Sprintf("Imported %d server(s).", msg.count) + return m, nil + case forwardsLoadedMsg: if m.forwardScreen != nil { if msg.err != nil { @@ -1093,9 +1116,13 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionMenu = nil return m, m.tunnelScreen.loadTunnels() case "route": - m.err = fmt.Errorf("route management not yet implemented in TUI") - m.screen = screenList - m.actionMenu = nil + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.form = newEditFormModel(item.server, m.width, m.height) + m.form.focusIdx = 7 + m.form.updateFocus() + m.screen = screenForm + m.actionMenu = nil + } case "test": if item, ok := m.list.SelectedItem().(serverItem); ok { m.screen = screenList @@ -1128,19 +1155,35 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "import": m.screen = screenList m.actionMenu = nil - m.err = fmt.Errorf("import not yet implemented") + return m, func() tea.Msg { + if ImportServers == nil { + return importDoneMsg{err: fmt.Errorf("import is unavailable")} + } + count, err := ImportServers() + if err != nil { + return importDoneMsg{err: err} + } + servers, err := ListServers() + return importDoneMsg{servers: servers, count: count, err: err} + } case "export": - m.screen = screenList m.actionMenu = nil - m.err = fmt.Errorf("export not yet implemented") + m.result = &TUIResult{Action: "export"} + return m, tea.Quit case "vault_lock": m.screen = screenList m.actionMenu = nil - m.err = fmt.Errorf("vault lock not yet implemented") + if LockVault == nil { + m.err = fmt.Errorf("vault lock is unavailable") + } else if err := LockVault(); err != nil { + m.err = err + } else { + m.success = "Vault locked." + } case "vault_change_pw": - m.screen = screenList m.actionMenu = nil - m.err = fmt.Errorf("vault change password not yet implemented") + m.result = &TUIResult{Action: "vault_change_pw"} + return m, tea.Quit } return m, nil } @@ -1781,7 +1824,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) [] if hasBackgroundResult { items = append(items, helpItem{Key: "Esc", Action: "clear result"}) } - items = append(items, + items = append(items, helpItem{Key: "Enter", Action: "connect"}, helpItem{Key: "Ctrl+X", Action: "actions"}, helpItem{Key: "Ctrl+A", Action: "add"}, diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 0492db6..d47ec83 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -819,3 +819,138 @@ func TestActionMenuClosesOnAllActions(t *testing.T) { t.Fatalf("expected screenForwardList, got %v", m.screen) } } + +func TestActionMenuManageRouteOpensRouteField(t *testing.T) { + server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} + m := New([]*model.Server{server}) + m.width = 100 + m.height = 30 + m.actionMenu = newActionMenuModel(m.width, m.height) + m.screen = screenActionMenu + for i := 0; i < len(m.actionMenu.list.Items()); i++ { + m.actionMenu.list.Select(i) + if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "route" { + break + } + } + + updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + + if m.err != nil && strings.Contains(m.err.Error(), "not yet implemented") { + t.Fatalf("route action should not be a stub: %v", m.err) + } + if m.screen != screenForm || m.form == nil { + t.Fatalf("expected route action to open edit form, screen=%v form=%v", m.screen, m.form) + } + if m.form.focusIdx != 7 { + t.Fatalf("expected route field focus index 7, got %d", m.form.focusIdx) + } +} + +func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) { + server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} + imported := false + ImportServers = func() (int, error) { + imported = true + return 2, nil + } + ListServers = func() ([]*model.Server, error) { + return []*model.Server{server}, nil + } + defer func() { + ImportServers = nil + ListServers = nil + }() + + m := New([]*model.Server{}) + m.width = 100 + m.height = 30 + m.actionMenu = newActionMenuModel(m.width, m.height) + m.screen = screenActionMenu + for i := 0; i < len(m.actionMenu.list.Items()); i++ { + m.actionMenu.list.Select(i) + if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" { + break + } + } + + updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + if cmd == nil { + t.Fatal("expected import command") + } + msg := cmd() + updated, _ = m.Update(msg) + m = updated.(*tuiModel) + + if !imported { + t.Fatal("expected import callback to run") + } + if len(m.servers) != 1 || m.servers[0].Alias != "web" { + t.Fatalf("expected refreshed server list, got %#v", m.servers) + } + if !strings.Contains(m.success, "Imported 2") { + t.Fatalf("expected import success message, got %q", m.success) + } +} + +func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) { + server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} + for _, action := range []string{"export", "vault_change_pw"} { + t.Run(action, func(t *testing.T) { + m := New([]*model.Server{server}) + m.width = 100 + m.height = 30 + m.actionMenu = newActionMenuModel(m.width, m.height) + m.screen = screenActionMenu + for i := 0; i < len(m.actionMenu.list.Items()); i++ { + m.actionMenu.list.Select(i) + if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action { + break + } + } + + updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + if cmd == nil { + t.Fatalf("expected %s to quit TUI", action) + } + if m.result == nil || m.result.Action != action { + t.Fatalf("expected result action %q, got %#v", action, m.result) + } + }) + } +} + +func TestActionMenuVaultLockUsesCallback(t *testing.T) { + server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} + locked := false + LockVault = func() error { + locked = true + return nil + } + defer func() { LockVault = nil }() + + m := New([]*model.Server{server}) + m.width = 100 + m.height = 30 + m.actionMenu = newActionMenuModel(m.width, m.height) + m.screen = screenActionMenu + for i := 0; i < len(m.actionMenu.list.Items()); i++ { + m.actionMenu.list.Select(i) + if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" { + break + } + } + + updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + + if !locked { + t.Fatal("expected vault lock callback to run") + } + if !strings.Contains(m.success, "Vault locked") { + t.Fatalf("expected vault lock success, got %q", m.success) + } +}