feat: replace tui action stubs
This commit is contained in:
parent
7e0a00ff43
commit
86e5bb5f0c
76
cmd/extra.go
76
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 <alias> <command>",
|
||||
Short: "Run a command on a server",
|
||||
|
|
|
|||
37
cmd/tui.go
37
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)
|
||||
|
|
|
|||
72
cmd/vault.go
72
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue