From c2b0e57f3aee96d8b227a8dcec0c8afa00874650 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Thu, 28 May 2026 17:36:50 +0800 Subject: [PATCH] Add TUI tag and template management --- README.md | 17 +- cmd/add.go | 66 +- cmd/add_test.go | 10 +- cmd/edit.go | 17 + cmd/extra.go | 47 +- cmd/show.go | 7 + cmd/template.go | 105 +- cmd/tui.go | 63 +- .../2026-05-28-tui-tags-global-templates.md | 51 + internal/db/db.go | 59 + internal/db/servers.go | 160 ++- internal/db/servers_test.go | 187 +++ internal/model/server.go | 18 +- internal/ssh/command.go | 70 + internal/ssh/command_test.go | 33 + internal/tui/app.go | 1122 ++++++++++++++++- internal/tui/app_test.go | 292 ++++- 17 files changed, 2129 insertions(+), 195 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-tui-tags-global-templates.md diff --git a/README.md b/README.md index 7396c8c..f50bf4f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,13 @@ sshkeeper run web "uptime" # Groups and templates sshkeeper group list -sshkeeper template list web +sshkeeper template list +sshkeeper template add uptime "uptime" +sshkeeper run-template web uptime + +# Tags and startup command +sshkeeper add web --host 10.0.0.10 --user deploy --auth key --tags prod,web --startup-command "tmux attach -t ops" +sshkeeper edit web --tags prod,web --startup-command "tmux attach -t ops" # OpenSSH config sshkeeper ssh-config generate @@ -86,13 +92,22 @@ Running `sshkeeper` without arguments opens the TUI. | Key | Action | | --- | --- | | Enter | Connect to selected server | +| Ctrl+R | Pick and run a command template on the selected servers | +| Insert | Select or unselect a server, then move to the next row | | Ctrl+A | Add server | | Ctrl+E | Edit server | | Ctrl+D | Delete server | | Ctrl+T | Test connection | | Ctrl+F | Search | +| Ctrl+G | Manage tags | +| Ctrl+P | Manage global command templates | | Ctrl+Q / Ctrl+C | Quit | +Templates are global entities and can run on any server. Foreground template +runs leave the TUI, show the SSH session in the terminal, and then return to the +TUI. Background runs execute the command and show per-server output in a result +screen. + In add/edit forms: | Key | Action | diff --git a/cmd/add.go b/cmd/add.go index 9e0d397..6645cc8 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -24,6 +24,7 @@ var addFlags struct { groupName string displayName string notes string + startup string tags string } @@ -50,16 +51,17 @@ func addInteractive() error { func addNonInteractive(alias string) error { server := &model.Server{ - Alias: alias, - DisplayName: addFlags.displayName, - Host: addFlags.host, - Port: addFlags.port, - User: addFlags.user, - AuthMethod: model.AuthMethod(addFlags.authMethod), - IdentityFile: addFlags.identityFile, - ProxyJump: addFlags.proxyJump, - GroupName: addFlags.groupName, - Notes: addFlags.notes, + Alias: alias, + DisplayName: addFlags.displayName, + Host: addFlags.host, + Port: addFlags.port, + User: addFlags.user, + AuthMethod: model.AuthMethod(addFlags.authMethod), + IdentityFile: addFlags.identityFile, + ProxyJump: addFlags.proxyJump, + GroupName: addFlags.groupName, + Notes: addFlags.notes, + StartupCommand: addFlags.startup, } if server.Port == 0 { @@ -118,14 +120,11 @@ func saveServerWithOptionalSecret(server *model.Server) error { } if addFlags.tags != "" { - tagList := strings.Split(addFlags.tags, ",") - for _, t := range tagList { - t = strings.TrimSpace(t) - if t != "" { - if err := appDB.AddTagToServer(server.ID, t); err != nil { - return fmt.Errorf("add tag %s: %w", t, err) - } - } + server.Tags = strings.Split(addFlags.tags, ",") + } + if len(server.Tags) > 0 { + if err := appDB.SetServerTags(server.ID, server.Tags); err != nil { + return fmt.Errorf("set tags: %w", err) } } @@ -184,18 +183,28 @@ func promptServerForAdd(in io.Reader, out io.Writer) (*model.Server, error) { if err != nil { return nil, err } + startupCommand, err := promptOptional(reader, out, "Startup command", "") + if err != nil { + return nil, err + } + tagsText, err := promptOptional(reader, out, "Tags (comma-separated)", "") + if err != nil { + return nil, err + } return &model.Server{ - Alias: alias, - DisplayName: displayName, - Host: host, - Port: port, - User: user, - AuthMethod: authMethod, - IdentityFile: identityFile, - ProxyJump: proxyJump, - GroupName: groupName, - Notes: notes, + Alias: alias, + DisplayName: displayName, + Host: host, + Port: port, + User: user, + AuthMethod: authMethod, + IdentityFile: identityFile, + ProxyJump: proxyJump, + GroupName: groupName, + Notes: notes, + StartupCommand: startupCommand, + Tags: strings.Split(tagsText, ","), }, nil } @@ -248,5 +257,6 @@ func init() { addCmd.Flags().StringVar(&addFlags.groupName, "group", "", "Server group") addCmd.Flags().StringVar(&addFlags.displayName, "display-name", "", "Display name") addCmd.Flags().StringVar(&addFlags.notes, "notes", "", "Notes") + addCmd.Flags().StringVar(&addFlags.startup, "startup-command", "", "Command to run after connecting") addCmd.Flags().StringVar(&addFlags.tags, "tags", "", "Comma-separated tags") } diff --git a/cmd/add_test.go b/cmd/add_test.go index 3c17417..e55c60f 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -20,6 +20,8 @@ func TestPromptServerForAddCollectsInteractiveFields(t *testing.T) { "bastion", "prod", "critical host", + "tmux attach -t prod", + "prod,web", "", }, "\n")) var output bytes.Buffer @@ -38,9 +40,13 @@ func TestPromptServerForAddCollectsInteractiveFields(t *testing.T) { server.IdentityFile != "~/.ssh/id_prod" || server.ProxyJump != "bastion" || server.GroupName != "prod" || - server.Notes != "critical host" { + server.Notes != "critical host" || + server.StartupCommand != "tmux attach -t prod" { t.Fatalf("unexpected server: %#v", server) } + if strings.Join(server.Tags, ",") != "prod,web" { + t.Fatalf("tags = %#v", server.Tags) + } if strings.Contains(output.String(), "not yet implemented") { t.Fatalf("interactive add should not report unimplemented:\n%s", output.String()) } @@ -59,6 +65,8 @@ func TestPromptServerForAddAppliesDefaults(t *testing.T) { "", "", "", + "", + "", }, "\n")) var output bytes.Buffer diff --git a/cmd/edit.go b/cmd/edit.go index a24e287..527e103 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "syscall" "github.com/mirivlad/sshkeeper/internal/model" @@ -49,6 +50,13 @@ var editCmd = &cobra.Command{ if parsedNotes != "" { server.Notes = parsedNotes } + if parsedStartup != "" { + server.StartupCommand = parsedStartup + } + tagsChanged := cmd.Flags().Changed("tags") + if tagsChanged { + server.Tags = strings.Split(parsedTags, ",") + } if parsedAuth != "" && oldAuthMethod != server.AuthMethod { v := getOrCreateVault() @@ -88,6 +96,11 @@ var editCmd = &cobra.Command{ if err := appDB.UpdateServer(server); err != nil { return fmt.Errorf("update server: %w", err) } + if tagsChanged { + if err := appDB.SetServerTags(server.ID, server.Tags); err != nil { + return fmt.Errorf("set tags: %w", err) + } + } fmt.Println("Saved.") return nil @@ -104,6 +117,8 @@ var ( parsedGroup string parsedDisplayName string parsedNotes string + parsedStartup string + parsedTags string ) func init() { @@ -116,4 +131,6 @@ func init() { editCmd.Flags().StringVar(&parsedGroup, "group", "", "Server group") editCmd.Flags().StringVar(&parsedDisplayName, "display-name", "", "Display name") editCmd.Flags().StringVar(&parsedNotes, "notes", "", "Notes") + editCmd.Flags().StringVar(&parsedStartup, "startup-command", "", "Command to run after connecting") + editCmd.Flags().StringVar(&parsedTags, "tags", "", "Comma-separated tags") } diff --git a/cmd/extra.go b/cmd/extra.go index d78917b..f59fc8d 100644 --- a/cmd/extra.go +++ b/cmd/extra.go @@ -2,8 +2,6 @@ package cmd import ( "fmt" - "os" - "os/exec" "strings" "github.com/mirivlad/sshkeeper/internal/model" @@ -74,46 +72,23 @@ var runCmd = &cobra.Command{ return fmt.Errorf("server not found: %s", alias) } - if server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase { - return runWithSecret(server, command) - } - - // For key/agent auth — direct execution - sshArgs := ssh.BuildSSHArgs(server) - sshArgs = append(sshArgs, command) - - sshCmd := exec.Command(cfg.SSH.Binary, sshArgs...) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - - if err := sshCmd.Start(); err != nil { - return fmt.Errorf("start ssh: %w", err) - } - - return sshCmd.Wait() + return runCommandOnServer(server, command) }, } -// runWithSecret runs a command on a server through the PTY prompt handler. -func runWithSecret(server *model.Server, command string) error { +func runCommandOnServer(server *model.Server, command string) error { + return ssh.RunCommand(cfg, server, commandVaultFunc, command) +} + +func commandVaultFunc(serverAlias string, secretType string) (string, error) { v := getOrCreateVault() if !v.IsUnlocked() { - return fmt.Errorf("%s", vaultLockedProcessMessage()) + return "", fmt.Errorf("%s", vaultLockedProcessMessage()) } - - secretType := "ssh_password" - if server.AuthMethod == model.AuthKeyPassphrase { - secretType = "key_passphrase" - } - vaultKey := fmt.Sprintf("server:%s:%s", server.Alias, secretType) - secret, err := v.Get(vaultKey) + vaultKey := fmt.Sprintf("server:%s:%s", serverAlias, secretType) + data, err := v.Get(vaultKey) if err != nil { - return fmt.Errorf("get %s from vault: %w", secretType, err) + return "", err } - - sshArgs := ssh.BuildSSHArgs(server) - sshArgs = append(sshArgs, command) - - return ssh.ConnectWithPassword(cfg.SSH.Binary, sshArgs, string(secret)) + return string(data), nil } diff --git a/cmd/show.go b/cmd/show.go index c5f6945..0e729a9 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" ) @@ -32,6 +33,12 @@ var showCmd = &cobra.Command{ if server.GroupName != "" { fmt.Printf("Group: %s\n", server.GroupName) } + if len(server.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(server.Tags, ", ")) + } + if server.StartupCommand != "" { + fmt.Printf("Startup Cmd: %s\n", server.StartupCommand) + } if server.Notes != "" { fmt.Printf("Notes: %s\n", server.Notes) } diff --git a/cmd/template.go b/cmd/template.go index 3d4a01d..cc8edfa 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -2,30 +2,22 @@ package cmd import ( "fmt" - "os" - "os/exec" + "github.com/mirivlad/sshkeeper/internal/model" "github.com/spf13/cobra" - "github.com/mirivlad/sshkeeper/internal/ssh" ) var templateCmd = &cobra.Command{ Use: "template", - Short: "Command template management", + Short: "Global command template management", } var templateListCmd = &cobra.Command{ - Use: "list ", - Short: "List command templates for a server", - Args: cobra.ExactArgs(1), + Use: "list", + Short: "List global command templates", + Args: cobra.NoArgs, 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) + templates, err := appDB.ListCommandTemplates() if err != nil { return fmt.Errorf("list templates: %w", err) } @@ -36,27 +28,19 @@ var templateListCmd = &cobra.Command{ } for _, t := range templates { - fmt.Printf(" %-15s %s\n", t.Name, t.Command) + fmt.Printf(" %-20s %s\n", t.Name, t.Command) } return nil }, } var templateAddCmd = &cobra.Command{ - Use: "add ", - Short: "Add a command template", - Args: cobra.ExactArgs(3), + Use: "add ", + Short: "Add a global command template", + Args: cobra.ExactArgs(2), 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 { + t := &model.CommandTemplate{Name: args[0], Command: args[1]} + if err := appDB.CreateCommandTemplate(t); err != nil { return fmt.Errorf("add template: %w", err) } @@ -65,9 +49,38 @@ var templateAddCmd = &cobra.Command{ }, } +var templateEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a global command template", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + t := &model.CommandTemplate{Name: args[1], Command: args[2]} + if err := appDB.UpdateCommandTemplate(args[0], t); err != nil { + return fmt.Errorf("edit template: %w", err) + } + + fmt.Println("Template saved.") + return nil + }, +} + +var templateDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a global command template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := appDB.DeleteCommandTemplate(args[0]); err != nil { + return fmt.Errorf("delete template: %w", err) + } + + fmt.Println("Template deleted.") + return nil + }, +} + var runTemplateCmd = &cobra.Command{ Use: "run-template