From d6b810de479a352b769af97ce605adefee76c32e Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 13:52:10 +0700 Subject: [PATCH] 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 --- PROJECT_STATE.md | 36 ++++- cmd/hostkeeper/delete.go | 82 ++++++++++ cmd/hostkeeper/delete_test.go | 34 +++++ cmd/hostkeeper/edit.go | 275 ++++++++++++++++++++++++++++++++++ cmd/hostkeeper/edit_test.go | 34 +++++ 5 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 cmd/hostkeeper/delete.go create mode 100644 cmd/hostkeeper/delete_test.go create mode 100644 cmd/hostkeeper/edit.go create mode 100644 cmd/hostkeeper/edit_test.go diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 3ac191a..9c61415 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,7 +2,7 @@ > **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions > -> **Last Updated**: 2024-06-23 (Session 5) +> **Last Updated**: 2024-06-23 (Session 6) > **Current Status**: Implementation In Progress - Tasks 1-8 Complete > **Phase**: MVP Development (Phase 1) @@ -33,7 +33,8 @@ ✅ **Bug Fix**: Fixed deadlock in JSON storage (RLock within Lock) ### What Needs to Happen Next -🔄 **Task 10+**: Edit/remove commands, TUI implementation +🔄 **Task 10**: TUI implementation (Bubble Tea) +🔄 **Task 11**: Export/Import commands 🔄 Build and test core features 🔄 Prepare MVP release @@ -52,12 +53,12 @@ | **Errors** | ✅ 100% | AppError + ConnectionError + SSH error handler | | **SSH Client** | ✅ 100% | Password + key auth, Execute, Connect/Close | | **CLI Framework** | ✅ 100% | Cobra root, version, completion commands | -| **CLI Commands** | 🟡 40% | add + list + connect commands done; edit/remove pending | +| **CLI Commands** | 🟡 60% | add + list + connect + edit + delete commands done | | **TUI** | 🔲 0% | Terminal user interface | -| **Testing** | 🟡 45% | Error + SSH + add + list + connect tests passing | +| **Testing** | 🟡 50% | Error + SSH + add + list + connect + edit + delete tests passing | | **Documentation** | 🔲 0% | Usage guides and API docs | -### Overall Progress: **~60% Complete** (Tasks 1-9 done) +### Overall Progress: **~65% Complete** (Tasks 1-9 + edit/delete done) --- @@ -142,6 +143,22 @@ - `cmd/hostkeeper/connect.go` — Connect command with native SSH (default) and direct Go SSH (--direct) modes - `cmd/hostkeeper/connect_test.go` — Tests for command existence, flags, and SSH arg building +#### ✅ Edit Host Command +- **Status**: ✅ Completed +- **Priority**: HIGH +- **Deliverables**: Edit existing SSH host configurations +- **Files Created**: + - `cmd/hostkeeper/edit.go` — Edit command with flag-based and interactive modes + - `cmd/hostkeeper/edit_test.go` — Tests for command existence and flags + +#### ✅ Delete Host Command +- **Status**: ✅ Completed +- **Priority**: HIGH +- **Deliverables**: Delete SSH hosts with confirmation prompt +- **Files Created**: + - `cmd/hostkeeper/delete.go` — Delete command with --force flag to skip confirmation + - `cmd/hostkeeper/delete_test.go` — Tests for command existence, alias, and flags + #### 🔲 Task 10-14: Remaining Tasks - **Status**: Not Started - **Details**: See `docs/plans/2024-06-22-hostkeeper-implementation.md` @@ -163,11 +180,13 @@ - [x] Add host command - [x] List hosts command - [x] Connect host command +- [x] Edit host command +- [x] Delete host command ### Next Sprint -- [ ] CLI commands (edit, remove) -- [ ] Basic TUI implementation +- [ ] TUI implementation (Bubble Tea) - [ ] Export/import functionality +- [ ] Key management commands ### Final Sprint - [ ] Testing and integration @@ -528,7 +547,8 @@ cat go.mod ### Milestone Tracking - [x] Milestone 1: Foundation (Tasks 1-6) - Week 1 ✅ COMPLETE - [x] Task 7-9: Add, List, Connect commands ✅ COMPLETE -- [ ] Milestone 2: Core Features (Tasks 7-10) - Week 2-3 (80% complete) +- [x] Edit & Delete commands ✅ COMPLETE +- [ ] Milestone 2: Core Features (Tasks 7-10) - Week 2-3 (85% complete) - [ ] Milestone 3: Polish & Release (Tasks 11-14) - Week 4 --- diff --git a/cmd/hostkeeper/delete.go b/cmd/hostkeeper/delete.go new file mode 100644 index 0000000..869fe1d --- /dev/null +++ b/cmd/hostkeeper/delete.go @@ -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 ", + 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 +} diff --git a/cmd/hostkeeper/delete_test.go b/cmd/hostkeeper/delete_test.go new file mode 100644 index 0000000..9521a38 --- /dev/null +++ b/cmd/hostkeeper/delete_test.go @@ -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 " { + t.Errorf("expected Use 'delete ', 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") + } +} diff --git a/cmd/hostkeeper/edit.go b/cmd/hostkeeper/edit.go new file mode 100644 index 0000000..6fd8fd2 --- /dev/null +++ b/cmd/hostkeeper/edit.go @@ -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 ", + 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(¬es) + 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 +} diff --git a/cmd/hostkeeper/edit_test.go b/cmd/hostkeeper/edit_test.go new file mode 100644 index 0000000..daac54a --- /dev/null +++ b/cmd/hostkeeper/edit_test.go @@ -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 " { + t.Errorf("expected Use 'edit ', 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") + } +}