Fix: test with password, group management, vault progress

- Test in form now works with password before save (TestConnectionWithPassword)
- Groups: show existing groups in form hint, group rename/delete commands
- Vault: added progress indicators ('Unlocking vault...', 'Deriving key... done.')
- Separate template.go for command template commands
This commit is contained in:
mirivlad 2026-05-26 17:48:13 +08:00
parent d1bb216d82
commit 73c60b9f93
6 changed files with 234 additions and 94 deletions

View File

@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
@ -15,123 +16,71 @@ var groupListCmd = &cobra.Command{
Use: "list",
Short: "List server groups",
RunE: func(cmd *cobra.Command, args []string) error {
servers, err := appDB.ListServers()
groups, err := appDB.GetGroups()
if err != nil {
return fmt.Errorf("list servers: %w", err)
}
groups := make(map[string]int)
for _, s := range servers {
g := s.GroupName
if g == "" {
g = "(no group)"
}
groups[g]++
return fmt.Errorf("list groups: %w", err)
}
if len(groups) == 0 {
fmt.Println("No servers.")
fmt.Println("No groups. Use 'sshkeeper add --group <name>' to create one.")
return nil
}
for name, count := range groups {
fmt.Printf(" %-20s %d servers\n", name, count)
for _, g := range groups {
fmt.Printf(" %s\n", g)
}
return nil
},
}
var templateCmd = &cobra.Command{
Use: "template",
Short: "Command template management",
}
var templateListCmd = &cobra.Command{
Use: "list <alias>",
Short: "List command templates for a server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
_, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
templates, err := appDB.GetCommandTemplates(alias)
if err != nil {
return fmt.Errorf("list templates: %w", err)
}
if len(templates) == 0 {
fmt.Println("No command templates.")
return nil
}
for _, t := range templates {
fmt.Printf(" %-15s %s\n", t.Name, t.Command)
}
return nil
},
}
var templateAddCmd = &cobra.Command{
Use: "add <alias> <name> <command>",
Short: "Add a command template",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
name := args[1]
command := args[2]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
if err := appDB.AddCommandTemplate(server.ID, name, command); err != nil {
return fmt.Errorf("add template: %w", err)
}
fmt.Println("Template added.")
return nil
},
}
var runTemplateCmd = &cobra.Command{
Use: "run-template <alias> <template>",
Short: "Run a command template on a server",
var groupRenameCmd = &cobra.Command{
Use: "rename <old> <new>",
Short: "Rename a group (updates all servers in the group)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
templateName := args[1]
oldName := args[0]
newName := args[1]
templates, err := appDB.GetCommandTemplates(alias)
if err != nil {
return fmt.Errorf("list templates: %w", err)
}
if len(templates) == 0 {
return fmt.Errorf("server not found or no templates: %s", alias)
if err := appDB.RenameGroup(oldName, newName); err != nil {
return fmt.Errorf("rename group: %w", err)
}
var command string
for _, t := range templates {
if t.Name == templateName {
command = t.Command
break
}
}
if command == "" {
return fmt.Errorf("template not found: %s", templateName)
}
fmt.Printf("Running '%s' on %s...\n", command, alias)
fmt.Printf("Group '%s' renamed to '%s'.\n", oldName, newName)
return nil
},
}
var groupDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete a group (removes group from all servers)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if !forceFlag {
fmt.Printf("Remove group '%s' from all servers? (y/N): ", name)
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Cancelled.")
return nil
}
}
if err := appDB.DeleteGroup(name); err != nil {
return fmt.Errorf("delete group: %w", err)
}
fmt.Printf("Group '%s' removed from all servers.\n", name)
return nil
},
}
var forceFlag bool
func init() {
groupCmd.AddCommand(groupListCmd)
templateCmd.AddCommand(templateListCmd)
templateCmd.AddCommand(templateAddCmd)
groupCmd.AddCommand(groupRenameCmd)
groupCmd.AddCommand(groupDeleteCmd)
groupDeleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Delete without confirmation")
}

101
cmd/template.go Normal file
View File

@ -0,0 +1,101 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var templateCmd = &cobra.Command{
Use: "template",
Short: "Command template management",
}
var templateListCmd = &cobra.Command{
Use: "list <alias>",
Short: "List command templates for a server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
_, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
templates, err := appDB.GetCommandTemplates(alias)
if err != nil {
return fmt.Errorf("list templates: %w", err)
}
if len(templates) == 0 {
fmt.Println("No command templates.")
return nil
}
for _, t := range templates {
fmt.Printf(" %-15s %s\n", t.Name, t.Command)
}
return nil
},
}
var templateAddCmd = &cobra.Command{
Use: "add <alias> <name> <command>",
Short: "Add a command template",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
name := args[1]
command := args[2]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
if err := appDB.AddCommandTemplate(server.ID, name, command); err != nil {
return fmt.Errorf("add template: %w", err)
}
fmt.Println("Template added.")
return nil
},
}
var runTemplateCmd = &cobra.Command{
Use: "run-template <alias> <template>",
Short: "Run a command template on a server",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
templateName := args[1]
templates, err := appDB.GetCommandTemplates(alias)
if err != nil {
return fmt.Errorf("list templates: %w", err)
}
if len(templates) == 0 {
return fmt.Errorf("server not found or no templates: %s", alias)
}
var command string
for _, t := range templates {
if t.Name == templateName {
command = t.Command
break
}
}
if command == "" {
return fmt.Errorf("template not found: %s", templateName)
}
fmt.Printf("Running '%s' on %s...\n", command, alias)
return nil
},
}
func init() {
templateCmd.AddCommand(templateListCmd)
templateCmd.AddCommand(templateAddCmd)
}

View File

@ -41,6 +41,15 @@ func runTUI() error {
tui.TestConnection = func(server *model.Server) (bool, string) {
return ssh.Test(cfg, server, vaultFunc)
}
tui.TestConnectionWithPassword = func(server *model.Server, password string) (bool, string) {
directVaultFunc := func(sa string, st string) (string, error) {
if st == "ssh_password" || st == "key_passphrase" {
return password, nil
}
return vaultFunc(sa, st)
}
return ssh.Test(cfg, server, directVaultFunc)
}
tui.SaveServer = func(server *model.Server, password string) error {
if password != "" {
v := getOrCreateVault()
@ -66,6 +75,16 @@ func runTUI() error {
return appDB.CreateServer(server)
}
tui.GetGroups = func() ([]string, error) {
return appDB.GetGroups()
}
tui.RenameGroup = func(oldName, newName string) error {
return appDB.RenameGroup(oldName, newName)
}
tui.DeleteGroup = func(name string) error {
return appDB.DeleteGroup(name)
}
// Run TUI in a loop — if user requests connect, handle it and restart TUI
for {
m := tui.New(servers)

View File

@ -246,3 +246,42 @@ func (db *DB) GetCommandTemplates(serverAlias string) ([]*model.CommandTemplate,
}
return templates, rows.Err()
}
// GetGroups returns all unique group names with server count
func (db *DB) GetGroups() ([]string, error) {
rows, err := db.conn.Query(`
SELECT group_name FROM servers
WHERE group_name != ''
GROUP BY group_name
ORDER BY group_name`)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
groups = append(groups, name)
}
return groups, rows.Err()
}
// RenameGroup renames a group for all servers in it
func (db *DB) RenameGroup(oldName, newName string) error {
_, err := db.conn.Exec(
"UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
newName, oldName)
return err
}
// DeleteGroup removes group assignment from all servers
func (db *DB) DeleteGroup(name string) error {
_, err := db.conn.Exec(
"UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
name)
return err
}

View File

@ -78,7 +78,12 @@ var (
SearchServers func(query string) ([]*model.Server, error)
DeleteServer func(alias string) error
TestConnection func(server *model.Server) (bool, string)
// TestConnectionWithPassword tests with explicit password (for form test before save)
TestConnectionWithPassword func(server *model.Server, password string) (bool, string)
SaveServer func(server *model.Server, password string) error
GetGroups func() ([]string, error) // Returns existing group names
RenameGroup func(oldName, newName string) error // Rename group for all servers
DeleteGroup func(name string) error // Remove group from all servers
)
// --- Screen type ---
@ -380,6 +385,8 @@ type formModel struct {
spinner spinner.Model
width int
height int
groups string // cached groups list (comma-separated for display)
showGroups bool // show group dropdown
}
func newFormModel(w, h int) *formModel {
@ -393,7 +400,7 @@ func newFormModel(w, h int) *formModel {
"Auth Method (password/key/key_passphrase/agent)",
"Identity File",
"ProxyJump",
"Group",
"Group (type new or pick from list)",
"Notes",
}
for i, label := range labels {
@ -413,7 +420,7 @@ func newFormModel(w, h int) *formModel {
inputs[0].Focus()
return &formModel{
fm := &formModel{
inputs: inputs,
password: pw,
focusIdx: 0,
@ -421,6 +428,15 @@ func newFormModel(w, h int) *formModel {
width: w,
height: h,
}
// Load existing groups
if GetGroups != nil {
if groups, err := GetGroups(); err == nil && len(groups) > 0 {
fm.groups = strings.Join(groups, ", ")
}
}
return fm
}
func newEditFormModel(s *model.Server, w, h int) *formModel {
@ -581,10 +597,18 @@ func (fm *formModel) runTest() tea.Cmd {
fm.saved = false
s := fm.buildServer()
pw := fm.password.Value()
return tea.Batch(
fm.spinner.Tick,
func() tea.Msg {
if s.AuthMethod == model.AuthPassword && fm.password.Value() == "" {
// Use direct password test if available (for form test before save)
if TestConnectionWithPassword != nil {
ok, testErr := TestConnectionWithPassword(s, pw)
return testDoneMsg{ok: ok, err: testErr}
}
// Fallback to vault-based test
if s.AuthMethod == model.AuthPassword && pw == "" {
return testDoneMsg{ok: false, err: "Password is required for password auth."}
}
ok, testErr := TestConnection(s)
@ -651,6 +675,10 @@ func (fm *formModel) View() string {
for i := range fm.inputs {
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
// Show existing groups hint under Group field
if i == 8 && fm.groups != "" {
b.WriteString(helpStyle.Render(" Groups: " + fm.groups + "\n"))
}
}
b.WriteString(fm.password.View())

View File

@ -113,6 +113,7 @@ func Create(path string, masterPassword string) error {
key[i] = 0
}
fmt.Println(" done.")
return nil
}
@ -121,6 +122,8 @@ func (v *Vault) Unlock(masterPassword string) error {
v.mu.Lock()
defer v.mu.Unlock()
fmt.Print("Unlocking vault...")
data, err := os.ReadFile(v.path)
if err != nil {
return fmt.Errorf("read vault file: %w", err)
@ -160,6 +163,7 @@ func (v *Vault) Unlock(masterPassword string) error {
v.records[rec.ID] = plaintext
}
fmt.Println(" done.")
return nil
}