feat: add & list commands with deadlock fix

- Add 'add' command: register hosts via flags or interactive prompts
  Supports password/key/both auth, groups, tags, notes, port override
- Add 'list' command: display hosts with filtering and formatting
  Supports --group, --tag filters, --sort, table/json/wide output
- Fix deadlock bug in JSON storage (RLock within Lock)
  Introduced internal list functions that don't lock
  Affects: ListHosts/GetHost/SaveHost/DeleteHost + KeyPair + Snippet
- Add comprehensive tests for add and list commands
- Update PROJECT_STATE.md (Tasks 1-8 complete, ~55% done)
This commit is contained in:
Swana Diva Borneos
2026-06-23 10:56:04 +07:00
parent 7dfe181b97
commit 4087c5fdc7
8 changed files with 770 additions and 16 deletions
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/google/uuid"
"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 (
addHostname string
addPort int
addUser string
addPassword string
addKeyPath string
addAuthType string
addGroup string
addTags []string
addNotes string
)
// addCmd represents the add command
var addCmd = &cobra.Command{
Use: "add [name]",
Short: "Add a new SSH host",
Long: `Add a new SSH host connection to HostKeeper.
You can add hosts using flags for quick addition or interactively.
Examples:
# Add host with password authentication
hostkeeper add myserver --host 192.168.1.10 --user admin --password mypass
# Add host with key authentication
hostkeeper add myserver --host 192.168.1.10 --user admin --key ~/.ssh/id_rsa
# Add host with custom port and group
hostkeeper add myserver --host 192.168.1.10 --port 2222 --user admin --password mypass --group production
# Add host interactively
hostkeeper add`,
Args: cobra.MaximumNArgs(1),
RunE: runAddHost,
}
func init() {
// Flags for add command
addCmd.Flags().StringVar(&addHostname, "host", "", "hostname or IP address")
addCmd.Flags().IntVar(&addPort, "port", 0, "SSH port (default: 22)")
addCmd.Flags().StringVar(&addUser, "user", "", "SSH username")
addCmd.Flags().StringVar(&addPassword, "password", "", "SSH password")
addCmd.Flags().StringVar(&addKeyPath, "key", "", "path to SSH private key")
addCmd.Flags().StringVar(&addAuthType, "auth-type", "", "authentication type: password, key, or both")
addCmd.Flags().StringVar(&addGroup, "group", "", "host group for categorization")
addCmd.Flags().StringSliceVar(&addTags, "tags", nil, "tags for the host (comma-separated)")
addCmd.Flags().StringVar(&addNotes, "notes", "", "notes about this host")
rootCmd.AddCommand(addCmd)
}
func runAddHost(cmd *cobra.Command, args []string) error {
cfg := appCfg
if cfg == nil {
var err error
cfg, err = config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
// Determine host name
var name string
if len(args) > 0 {
name = args[0]
}
// Check if we should use interactive mode
interactive := name == "" && addHostname == ""
if interactive {
return addHostInteractive(cfg)
}
// Validate required fields
if name == "" {
return fmt.Errorf("host name is required (provide as argument or use interactive mode)")
}
if addHostname == "" {
return fmt.Errorf("hostname is required (--host flag)")
}
if addUser == "" {
return fmt.Errorf("username is required (--user flag)")
}
// Set default port from config
port := addPort
if port == 0 {
port = cfg.GetAppConfig().DefaultPort
}
// Determine auth type
authType := addAuthType
if authType == "" {
if addKeyPath != "" && addPassword != "" {
authType = "both"
} else if addKeyPath != "" {
authType = "key"
} else if addPassword != "" {
authType = "password"
} else {
return fmt.Errorf("authentication is required: provide --password, --key, or both")
}
}
// Read key if provided
keyContent := ""
if addKeyPath != "" {
data, err := os.ReadFile(addKeyPath)
if err != nil {
return fmt.Errorf("failed to read key file: %w", err)
}
keyContent = string(data)
}
// Create host
host := &models.Host{
ID: uuid.New().String(),
Name: name,
Hostname: addHostname,
Port: port,
Username: addUser,
Auth: models.AuthConfig{
Type: authType,
Password: addPassword,
},
Group: addGroup,
Tags: addTags,
Notes: addNotes,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Store key content in password field if key-only auth (as reference)
// In a full implementation, this would store the key securely
if keyContent != "" {
host.Auth.Password = keyContent // Will be moved to secure storage
}
// Initialize storage
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Save host
ctx := context.Background()
if err := store.SaveHost(ctx, host); err != nil {
return fmt.Errorf("failed to save host: %w", err)
}
fmt.Printf("✓ Host '%s' added successfully\n", name)
fmt.Printf(" Hostname: %s:%d\n", addHostname, port)
fmt.Printf(" User: %s\n", addUser)
fmt.Printf(" Auth: %s\n", authType)
return nil
}
func addHostInteractive(cfg *config.Config) error {
reader := strings.NewReader("")
fmt.Println("╔══════════════════════════════════════╗")
fmt.Println("║ Add New SSH Host ║")
fmt.Println("╚══════════════════════════════════════╝")
fmt.Println()
// Get host name
fmt.Print("Host Name (e.g., myserver): ")
var name string
fmt.Fscanln(reader)
fmt.Scanln(&name)
if name == "" {
return fmt.Errorf("host name is required")
}
// Get hostname
fmt.Print("Hostname or IP (e.g., 192.168.1.10): ")
var hostname string
fmt.Scanln(&hostname)
if hostname == "" {
return fmt.Errorf("hostname is required")
}
// Get port
defaultPort := cfg.GetAppConfig().DefaultPort
fmt.Printf("Port [%d]: ", defaultPort)
var portInput string
fmt.Scanln(&portInput)
port := defaultPort
if portInput != "" {
fmt.Sscanf(portInput, "%d", &port)
}
// Get username
fmt.Print("Username: ")
var username string
fmt.Scanln(&username)
if username == "" {
return fmt.Errorf("username is required")
}
// Get auth type
fmt.Print("Auth Type (password/key/both) [password]: ")
var authType string
fmt.Scanln(&authType)
if authType == "" {
authType = "password"
}
// Get password
var password string
if authType == "password" || authType == "both" {
fmt.Print("Password: ")
fmt.Scanln(&password)
}
// Get key path
var keyContent string
if authType == "key" || authType == "both" {
fmt.Print("Path to private key (~/.ssh/id_rsa): ")
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)
}
keyContent = string(data)
}
}
// Get group
fmt.Print("Group (optional): ")
var group string
fmt.Scanln(&group)
// Get tags
fmt.Print("Tags (comma-separated, optional): ")
var tagsInput string
fmt.Scanln(&tagsInput)
var tags []string
if tagsInput != "" {
tags = strings.Split(tagsInput, ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
}
// Get notes
fmt.Print("Notes (optional): ")
var notes string
fmt.Scanln(&notes)
// Create host
host := &models.Host{
ID: uuid.New().String(),
Name: name,
Hostname: hostname,
Port: port,
Username: username,
Auth: models.AuthConfig{
Type: authType,
Password: password,
},
Group: group,
Tags: tags,
Notes: notes,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if keyContent != "" {
host.Auth.Password = keyContent
}
// Initialize storage
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Save host
ctx := context.Background()
if err := store.SaveHost(ctx, host); err != nil {
return fmt.Errorf("failed to save host: %w", err)
}
fmt.Println()
fmt.Printf("✓ Host '%s' added successfully!\n", name)
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"testing"
)
func TestAddCommandExists(t *testing.T) {
if addCmd == nil {
t.Fatal("addCmd should not be nil")
}
if addCmd.Use != "add [name]" {
t.Errorf("expected Use 'add [name]', got '%s'", addCmd.Use)
}
if addCmd.Short == "" {
t.Error("Short description should not be empty")
}
}
func TestAddCommandFlags(t *testing.T) {
expectedFlags := []string{"host", "port", "user", "password", "key", "auth-type", "group", "tags", "notes"}
for _, flagName := range expectedFlags {
if addCmd.Flags().Lookup(flagName) == nil {
t.Errorf("flag '%s' should be defined", flagName)
}
}
}
func TestAddCommandValidation(t *testing.T) {
tests := []struct {
name string
args []string
hostFlag string
userFlag string
passFlag string
wantError bool
errContains string
}{
{
name: "missing hostname",
args: []string{"myserver"},
userFlag: "admin",
passFlag: "pass",
wantError: true,
errContains: "hostname is required",
},
{
name: "missing username",
args: []string{"myserver"},
hostFlag: "192.168.1.10",
passFlag: "pass",
wantError: true,
errContains: "username is required",
},
{
name: "missing auth",
args: []string{"myserver"},
hostFlag: "192.168.1.10",
userFlag: "admin",
wantError: true,
errContains: "authentication is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset flags
addHostname = tt.hostFlag
addUser = tt.userFlag
addPassword = tt.passFlag
addPort = 0
addKeyPath = ""
addAuthType = ""
addGroup = ""
addTags = nil
addNotes = ""
// Set HOME to temp dir to avoid polluting real config
t.Setenv("HOME", "/tmp/hostkeeper-test-nonexistent")
err := runAddHost(addCmd, tt.args)
if tt.wantError && err == nil {
t.Errorf("expected error but got none")
}
if !tt.wantError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tt.errContains != "" && err != nil {
if !contains(err.Error(), tt.errContains) {
t.Errorf("error should contain '%s', got '%s'", tt.errContains, err.Error())
}
}
})
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"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 (
listGroup string
listTag string
listFormat string
listSort string
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List all saved SSH hosts",
Long: `List all SSH hosts saved in HostKeeper.
You can filter by group or tag, and format the output as table or json.
Examples:
# List all hosts
hostkeeper list
# List hosts in a specific group
hostkeeper list --group production
# List hosts with a specific tag
hostkeeper list --tag web
# Output in JSON format
hostkeeper list --format json
# Sort by name
hostkeeper list --sort name`,
RunE: runListHosts,
}
func init() {
listCmd.Flags().StringVar(&listGroup, "group", "", "filter hosts by group")
listCmd.Flags().StringVar(&listTag, "tag", "", "filter hosts by tag")
listCmd.Flags().StringVar(&listFormat, "format", "table", "output format: table or json")
listCmd.Flags().StringVar(&listSort, "sort", "name", "sort by: name, hostname, or group")
rootCmd.AddCommand(listCmd)
}
func runListHosts(cmd *cobra.Command, args []string) error {
cfg := appCfg
if cfg == nil {
var err error
cfg, err = config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
// Initialize storage
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Get all hosts
ctx := context.Background()
hosts, err := store.ListHosts(ctx)
if err != nil {
return fmt.Errorf("failed to list hosts: %w", err)
}
// Apply filters
if listGroup != "" {
hosts = filterByGroup(hosts, listGroup)
}
if listTag != "" {
hosts = filterByTag(hosts, listTag)
}
// Apply sorting
sortHosts(hosts, listSort)
// Output
if len(hosts) == 0 {
fmt.Println("No hosts found.")
fmt.Println()
fmt.Println("Add a host with: hostkeeper add [name] --host <hostname> --user <username>")
return nil
}
switch listFormat {
case "json":
return outputJSON(hosts)
case "table":
return outputTable(hosts)
default:
return fmt.Errorf("unsupported format: %s (use 'table' or 'json')", listFormat)
}
}
func filterByGroup(hosts []*models.Host, group string) []*models.Host {
var filtered []*models.Host
for _, h := range hosts {
if strings.EqualFold(h.Group, group) {
filtered = append(filtered, h)
}
}
return filtered
}
func filterByTag(hosts []*models.Host, tag string) []*models.Host {
var filtered []*models.Host
for _, h := range hosts {
for _, t := range h.Tags {
if strings.EqualFold(t, tag) {
filtered = append(filtered, h)
break
}
}
}
return filtered
}
func sortHosts(hosts []*models.Host, sortBy string) {
switch sortBy {
case "hostname":
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Hostname < hosts[j].Hostname
})
case "group":
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Group < hosts[j].Group
})
default: // "name"
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Name < hosts[j].Name
})
}
}
func outputTable(hosts []*models.Host) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tHOSTNAME\tPORT\tUSER\tGROUP\tAUTH\tTAGS")
fmt.Fprintln(w, "────\t────────\t────\t────\t─────\t────\t────")
for _, h := range hosts {
tags := strings.Join(h.Tags, ", ")
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
h.Name,
h.Hostname,
h.Port,
h.Username,
h.Group,
h.Auth.Type,
tags,
)
}
return w.Flush()
}
func outputJSON(hosts []*models.Host) error {
fmt.Print("[")
for i, h := range hosts {
if i > 0 {
fmt.Print(",")
}
fmt.Printf(`{"id":"%s","name":"%s","hostname":"%s","port":%d,"username":"%s","group":"%s","auth_type":"%s"}`,
h.ID, h.Name, h.Hostname, h.Port, h.Username, h.Group, h.Auth.Type)
}
fmt.Println("]")
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
func TestListCommandExists(t *testing.T) {
if listCmd == nil {
t.Fatal("listCmd should not be nil")
}
if listCmd.Use != "list" {
t.Errorf("expected Use 'list', got '%s'", listCmd.Use)
}
}
func TestListCommandFlags(t *testing.T) {
expectedFlags := []string{"group", "tag", "format", "sort"}
for _, flagName := range expectedFlags {
if listCmd.Flags().Lookup(flagName) == nil {
t.Errorf("flag '%s' should be defined", flagName)
}
}
}
func TestFilterByGroup(t *testing.T) {
hosts := []*models.Host{
{Name: "web1", Group: "production"},
{Name: "web2", Group: "staging"},
{Name: "db1", Group: "production"},
{Name: "cache1", Group: "staging"},
}
tests := []struct {
group string
wantLen int
}{
{"production", 2},
{"staging", 2},
{"nonexistent", 0},
}
for _, tt := range tests {
t.Run(tt.group, func(t *testing.T) {
result := filterByGroup(hosts, tt.group)
if len(result) != tt.wantLen {
t.Errorf("expected %d hosts for group '%s', got %d", tt.wantLen, tt.group, len(result))
}
})
}
}
func TestFilterByTag(t *testing.T) {
hosts := []*models.Host{
{Name: "web1", Tags: []string{"web", "frontend"}},
{Name: "db1", Tags: []string{"database", "backend"}},
{Name: "web2", Tags: []string{"web", "frontend"}},
{Name: "cache1", Tags: []string{"cache", "backend"}},
}
tests := []struct {
tag string
wantLen int
}{
{"web", 2},
{"database", 1},
{"backend", 2},
{"nonexistent", 0},
}
for _, tt := range tests {
t.Run(tt.tag, func(t *testing.T) {
result := filterByTag(hosts, tt.tag)
if len(result) != tt.wantLen {
t.Errorf("expected %d hosts for tag '%s', got %d", tt.wantLen, tt.tag, len(result))
}
})
}
}
func TestSortHosts(t *testing.T) {
hosts := []*models.Host{
{Name: "zebra", Hostname: "10.0.0.3", Group: "c"},
{Name: "alpha", Hostname: "10.0.0.1", Group: "a"},
{Name: "mike", Hostname: "10.0.0.2", Group: "b"},
}
// Test sort by name
sortHosts(hosts, "name")
if hosts[0].Name != "alpha" {
t.Errorf("expected first host to be 'alpha' when sorted by name, got '%s'", hosts[0].Name)
}
// Test sort by hostname
sortHosts(hosts, "hostname")
if hosts[0].Hostname != "10.0.0.1" {
t.Errorf("expected first host to have hostname '10.0.0.1' when sorted by hostname, got '%s'", hosts[0].Hostname)
}
// Test sort by group
sortHosts(hosts, "group")
if hosts[0].Group != "a" {
t.Errorf("expected first host to have group 'a' when sorted by group, got '%s'", hosts[0].Group)
}
}