feat: implement edit and delete host commands

- Add edit command with flag-based and interactive update modes
- Add delete command with confirmation prompt and --force flag
- Include delete alias 'rm' for convenience
- Add comprehensive tests for both commands
- Update project state documentation
This commit is contained in:
swanadiva
2026-06-23 13:52:10 +07:00
parent 368b7cdadb
commit d6b810de47
5 changed files with 453 additions and 8 deletions
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"fmt"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var deleteForce bool
// deleteCmd represents the delete command
var deleteCmd = &cobra.Command{
Use: "delete <host-name-or-id>",
Short: "Delete a saved SSH host",
Long: `Delete a saved SSH host from HostKeeper.
You will be prompted for confirmation unless --force is used.
Examples:
# Delete a host with confirmation
hostkeeper delete myserver
# Delete without confirmation
hostkeeper delete myserver --force`,
Aliases: []string{"rm"},
Args: cobra.ExactArgs(1),
RunE: runDeleteHost,
}
func init() {
deleteCmd.Flags().BoolVar(&deleteForce, "force", false, "delete without confirmation")
rootCmd.AddCommand(deleteCmd)
}
func runDeleteHost(cmd *cobra.Command, args []string) error {
hostIdentifier := args[0]
cfg := appCfg
if cfg == nil {
var err error
cfg, err = config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
ctx := context.Background()
host, err := findHost(ctx, store, hostIdentifier)
if err != nil {
return err
}
// Confirm deletion unless --force is used
if !deleteForce {
fmt.Printf("Are you sure you want to delete host '%s' (%s@%s:%d)? [y/N]: ",
host.Name, host.Username, host.Hostname, host.Port)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion cancelled.")
return nil
}
}
if err := store.DeleteHost(ctx, host.ID); err != nil {
return fmt.Errorf("failed to delete host: %w", err)
}
fmt.Printf("✓ Host '%s' deleted successfully\n", host.Name)
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"testing"
)
func TestDeleteCommandExists(t *testing.T) {
if deleteCmd == nil {
t.Fatal("deleteCmd should not be nil")
}
if deleteCmd.Use != "delete <host-name-or-id>" {
t.Errorf("expected Use 'delete <host-name-or-id>', got '%s'", deleteCmd.Use)
}
if deleteCmd.Short == "" {
t.Error("Short description should not be empty")
}
}
func TestDeleteCommandFlags(t *testing.T) {
expectedFlags := []string{"force"}
for _, flagName := range expectedFlags {
if deleteCmd.Flags().Lookup(flagName) == nil {
t.Errorf("flag '%s' should be defined", flagName)
}
}
}
func TestDeleteArgs(t *testing.T) {
if deleteCmd.Args == nil {
t.Error("Args validator should not be nil")
}
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var (
editHostname string
editPort int
editUser string
editPassword string
editKeyPath string
editAuthType string
editGroup string
editTags []string
editNotes string
editName string
)
// editCmd represents the edit command
var editCmd = &cobra.Command{
Use: "edit <host-name-or-id>",
Short: "Edit a saved SSH host",
Long: `Edit an existing SSH host configuration in HostKeeper.
You can update fields using flags or interactively.
Examples:
# Edit hostname and port
hostkeeper edit myserver --host 10.0.0.1 --port 2222
# Change username and auth
hostkeeper edit myserver --user root --auth-type key
# Edit interactively
hostkeeper edit myserver`,
Args: cobra.ExactArgs(1),
RunE: runEditHost,
}
func init() {
editCmd.Flags().StringVar(&editHostname, "host", "", "new hostname or IP address")
editCmd.Flags().IntVar(&editPort, "port", 0, "new SSH port")
editCmd.Flags().StringVar(&editUser, "user", "", "new SSH username")
editCmd.Flags().StringVar(&editPassword, "password", "", "new SSH password")
editCmd.Flags().StringVar(&editKeyPath, "key", "", "new path to SSH private key")
editCmd.Flags().StringVar(&editAuthType, "auth-type", "", "new authentication type: password, key, or both")
editCmd.Flags().StringVar(&editGroup, "group", "", "new host group")
editCmd.Flags().StringSliceVar(&editTags, "tags", nil, "new tags (comma-separated)")
editCmd.Flags().StringVar(&editNotes, "notes", "", "new notes")
editCmd.Flags().StringVar(&editName, "name", "", "new host name")
rootCmd.AddCommand(editCmd)
}
func runEditHost(cmd *cobra.Command, args []string) error {
hostIdentifier := args[0]
cfg := appCfg
if cfg == nil {
var err error
cfg, err = config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
ctx := context.Background()
host, err := findHost(ctx, store, hostIdentifier)
if err != nil {
return err
}
// Check if any flags were provided
flagProvided := cmd.Flags().Changed("host") || cmd.Flags().Changed("port") ||
cmd.Flags().Changed("user") || cmd.Flags().Changed("password") ||
cmd.Flags().Changed("key") || cmd.Flags().Changed("auth-type") ||
cmd.Flags().Changed("group") || cmd.Flags().Changed("tags") ||
cmd.Flags().Changed("notes") || cmd.Flags().Changed("name")
if !flagProvided {
return editHostInteractive(cfg, store, host)
}
// Apply flag-based updates
if cmd.Flags().Changed("host") {
host.Hostname = editHostname
}
if cmd.Flags().Changed("port") {
if editPort > 0 {
host.Port = editPort
} else {
host.Port = cfg.GetAppConfig().DefaultPort
}
}
if cmd.Flags().Changed("user") {
host.Username = editUser
}
if cmd.Flags().Changed("password") {
host.Auth.Password = editPassword
}
if cmd.Flags().Changed("auth-type") {
host.Auth.Type = editAuthType
}
if cmd.Flags().Changed("key") {
data, err := os.ReadFile(editKeyPath)
if err != nil {
return fmt.Errorf("failed to read key file: %w", err)
}
host.Auth.Password = string(data)
}
if cmd.Flags().Changed("group") {
host.Group = editGroup
}
if cmd.Flags().Changed("tags") {
host.Tags = editTags
}
if cmd.Flags().Changed("notes") {
host.Notes = editNotes
}
if cmd.Flags().Changed("name") {
host.Name = editName
}
host.UpdatedAt = time.Now()
if err := store.SaveHost(ctx, host); err != nil {
return fmt.Errorf("failed to update host: %w", err)
}
fmt.Printf("✓ Host '%s' updated successfully\n", host.Name)
return nil
}
func editHostInteractive(cfg *config.Config, store *storage.JSONStorage, host *models.Host) error {
fmt.Println("╔══════════════════════════════════════╗")
fmt.Println("║ Edit SSH Host ║")
fmt.Println("╚══════════════════════════════════════╝")
fmt.Println()
fmt.Println("Press Enter to keep the current value.")
fmt.Println()
// Name
fmt.Printf("Host Name [%s]: ", host.Name)
var name string
fmt.Scanln(&name)
if name != "" {
host.Name = name
}
// Hostname
fmt.Printf("Hostname or IP [%s]: ", host.Hostname)
var hostname string
fmt.Scanln(&hostname)
if hostname != "" {
host.Hostname = hostname
}
// Port
defaultPort := cfg.GetAppConfig().DefaultPort
fmt.Printf("Port [%d]: ", host.Port)
var portInput string
fmt.Scanln(&portInput)
if portInput != "" {
fmt.Sscanf(portInput, "%d", &host.Port)
} else if host.Port == 0 {
host.Port = defaultPort
}
// Username
fmt.Printf("Username [%s]: ", host.Username)
var username string
fmt.Scanln(&username)
if username != "" {
host.Username = username
}
// Auth type
currentAuth := host.Auth.Type
if currentAuth == "" {
currentAuth = "password"
}
fmt.Printf("Auth Type (password/key/both) [%s]: ", currentAuth)
var authType string
fmt.Scanln(&authType)
if authType != "" {
host.Auth.Type = authType
} else {
host.Auth.Type = currentAuth
}
// Password
if host.Auth.Type == "password" || host.Auth.Type == "both" {
prompt := "Password"
if host.Auth.Password != "" {
prompt += " [********]"
}
fmt.Printf("%s: ", prompt)
var password string
fmt.Scanln(&password)
if password != "" {
host.Auth.Password = password
}
}
// Key path
if host.Auth.Type == "key" || host.Auth.Type == "both" {
fmt.Printf("Path to private key: ")
var keyPath string
fmt.Scanln(&keyPath)
if keyPath != "" {
data, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to read key file: %w", err)
}
host.Auth.Password = string(data)
}
}
// Group
fmt.Printf("Group [%s]: ", host.Group)
var group string
fmt.Scanln(&group)
if group != "" {
host.Group = group
}
// Tags
currentTags := strings.Join(host.Tags, ",")
fmt.Printf("Tags (comma-separated) [%s]: ", currentTags)
var tagsInput string
fmt.Scanln(&tagsInput)
if tagsInput != "" {
tags := strings.Split(tagsInput, ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
host.Tags = tags
}
// Notes
fmt.Printf("Notes [%s]: ", host.Notes)
var notes string
fmt.Scanln(&notes)
if notes != "" {
host.Notes = notes
}
host.UpdatedAt = time.Now()
ctx := context.Background()
if err := store.SaveHost(ctx, host); err != nil {
return fmt.Errorf("failed to update host: %w", err)
}
fmt.Println()
fmt.Printf("✓ Host '%s' updated successfully!\n", host.Name)
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"testing"
)
func TestEditCommandExists(t *testing.T) {
if editCmd == nil {
t.Fatal("editCmd should not be nil")
}
if editCmd.Use != "edit <host-name-or-id>" {
t.Errorf("expected Use 'edit <host-name-or-id>', got '%s'", editCmd.Use)
}
if editCmd.Short == "" {
t.Error("Short description should not be empty")
}
}
func TestEditCommandFlags(t *testing.T) {
expectedFlags := []string{"host", "port", "user", "password", "key", "auth-type", "group", "tags", "notes", "name"}
for _, flagName := range expectedFlags {
if editCmd.Flags().Lookup(flagName) == nil {
t.Errorf("flag '%s' should be defined", flagName)
}
}
}
func TestEditArgs(t *testing.T) {
if editCmd.Args == nil {
t.Error("Args validator should not be nil")
}
}