feat: replace tui action stubs

This commit is contained in:
mirivlad 2026-06-06 03:26:17 +08:00
parent 7e0a00ff43
commit 86e5bb5f0c
5 changed files with 313 additions and 70 deletions

View File

@ -13,31 +13,12 @@ var importCmd = &cobra.Command{
Use: "import", Use: "import",
Short: "Import servers from ~/.ssh/config", Short: "Import servers from ~/.ssh/config",
RunE: func(cmd *cobra.Command, args []string) error { 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 { 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) fmt.Printf("\nImported %d servers.\n", imported)
return nil return nil
}, },
@ -52,13 +33,56 @@ var exportCmd = &cobra.Command{
return fmt.Errorf("list servers: %w", err) return fmt.Errorf("list servers: %w", err)
} }
for _, s := range servers { fmt.Print(formatServersExport(servers))
fmt.Printf("%s\t%s@%s:%d\t%s\n", s.Alias, s.User, s.Host, s.Port, s.AuthMethod)
}
return nil 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{ var runCmd = &cobra.Command{
Use: "run <alias> <command>", Use: "run <alias> <command>",
Short: "Run a command on a server", Short: "Run a command on a server",

View File

@ -138,6 +138,14 @@ func runTUI() error {
tui.DeleteForward = func(forwardID int64) error { tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID) 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 { tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
return appDB.UpdateTestResult(alias, status, testErr) return appDB.UpdateTestResult(alias, status, testErr)
} }
@ -213,6 +221,35 @@ func runTUI() error {
continue 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 { if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n" || result.Action == "tunnel_bg") && result.Server != nil {
server := result.Server server := result.Server
fresh, err := appDB.GetServer(server.Alias) fresh, err := appDB.GetServer(server.Alias)

View File

@ -118,40 +118,7 @@ var vaultChangePasswordCmd = &cobra.Command{
Use: "change-password", Use: "change-password",
Short: "Change master password", Short: "Change master password",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := getOrCreateVault() return changeVaultPasswordInteractive()
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
}, },
} }
@ -220,6 +187,43 @@ func unlockVaultForCommand(v *vault.Vault) error {
return nil 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 { func vaultLockedProcessMessage() string {
return "vault is locked in this process; enter the master password when this command prompts for it" return "vault is locked in this process; enter the master password when this command prompts for it"
} }

View File

@ -94,6 +94,12 @@ type forwardDeletedMsg struct {
err error err error
} }
type importDoneMsg struct {
servers []*model.Server
count int
err error
}
// --- List items --- // --- List items ---
type serverItem struct { type serverItem struct {
@ -171,6 +177,8 @@ var (
SaveForward func(fwd *model.Forward) error SaveForward func(fwd *model.Forward) error
UpdateForward func(fwd *model.Forward) error UpdateForward func(fwd *model.Forward) error
DeleteForward func(forwardID int64) error DeleteForward func(forwardID int64) error
ImportServers func() (int, error)
LockVault func() error
) )
// --- Screen type --- // --- Screen type ---
@ -240,6 +248,7 @@ type tuiModel struct {
confirmAction func() tea.Cmd confirmAction func() tea.Cmd
fullHelp *fullHelpModel fullHelp *fullHelpModel
} }
func New(servers []*model.Server) *tuiModel { func New(servers []*model.Server) *tuiModel {
items := make([]list.Item, len(servers)) items := make([]list.Item, len(servers))
for i, s := range servers { for i, s := range servers {
@ -354,6 +363,20 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pendingTemplate = nil m.pendingTemplate = nil
return m, 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: case forwardsLoadedMsg:
if m.forwardScreen != nil { if m.forwardScreen != nil {
if msg.err != nil { if msg.err != nil {
@ -1093,9 +1116,13 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.actionMenu = nil m.actionMenu = nil
return m, m.tunnelScreen.loadTunnels() return m, m.tunnelScreen.loadTunnels()
case "route": case "route":
m.err = fmt.Errorf("route management not yet implemented in TUI") if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList m.form = newEditFormModel(item.server, m.width, m.height)
m.actionMenu = nil m.form.focusIdx = 7
m.form.updateFocus()
m.screen = screenForm
m.actionMenu = nil
}
case "test": case "test":
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList m.screen = screenList
@ -1128,19 +1155,35 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "import": case "import":
m.screen = screenList m.screen = screenList
m.actionMenu = nil 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": case "export":
m.screen = screenList
m.actionMenu = nil m.actionMenu = nil
m.err = fmt.Errorf("export not yet implemented") m.result = &TUIResult{Action: "export"}
return m, tea.Quit
case "vault_lock": case "vault_lock":
m.screen = screenList m.screen = screenList
m.actionMenu = nil 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": case "vault_change_pw":
m.screen = screenList
m.actionMenu = nil 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 return m, nil
} }
@ -1781,7 +1824,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
if hasBackgroundResult { if hasBackgroundResult {
items = append(items, helpItem{Key: "Esc", Action: "clear result"}) items = append(items, helpItem{Key: "Esc", Action: "clear result"})
} }
items = append(items, items = append(items,
helpItem{Key: "Enter", Action: "connect"}, helpItem{Key: "Enter", Action: "connect"},
helpItem{Key: "Ctrl+X", Action: "actions"}, helpItem{Key: "Ctrl+X", Action: "actions"},
helpItem{Key: "Ctrl+A", Action: "add"}, helpItem{Key: "Ctrl+A", Action: "add"},

View File

@ -819,3 +819,138 @@ func TestActionMenuClosesOnAllActions(t *testing.T) {
t.Fatalf("expected screenForwardList, got %v", m.screen) 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)
}
}