4087c5fdc7
- 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)
309 lines
7.5 KiB
Go
309 lines
7.5 KiB
Go
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(¬es)
|
|
|
|
// 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
|
|
} |