refactor: move V1 code into v1/ subdirectory

- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/
- Create v1/README.md with V1 documentation
- Update root README for V1 + V2 structure
- V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass
- Root is now clean for V2 development
This commit is contained in:
swanadiva
2026-07-07 11:56:27 +07:00
parent 8ebdebedc8
commit 847989df75
68 changed files with 58 additions and 116 deletions
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bufio"
"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"
)
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 := newStorage(cfg)
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 {
input := bufio.NewReader(os.Stdin)
fmt.Println("╔══════════════════════════════════════╗")
fmt.Println("║ Add New SSH Host ║")
fmt.Println("╚══════════════════════════════════════╝")
fmt.Println()
readLine := func(prompt string) string {
fmt.Print(prompt)
line, _ := input.ReadString('\n')
return strings.TrimRight(line, "\n\r")
}
// Get host name
name := readLine("Host Name (e.g., myserver): ")
if name == "" {
return fmt.Errorf("host name is required")
}
// Get hostname
hostname := readLine("Hostname or IP (e.g., 192.168.1.10): ")
if hostname == "" {
return fmt.Errorf("hostname is required")
}
// Get port
defaultPort := cfg.GetAppConfig().DefaultPort
portInput := readLine(fmt.Sprintf("Port [%d]: ", defaultPort))
port := defaultPort
if portInput != "" {
fmt.Sscanf(portInput, "%d", &port)
}
// Get username
username := readLine("Username: ")
if username == "" {
return fmt.Errorf("username is required")
}
// Get auth type
authType := readLine("Auth Type (password/key/both) [password]: ")
if authType == "" {
authType = "password"
}
// Get password
var password string
if authType == "password" || authType == "both" {
password = readLine("Password: ")
}
// Get key path
var keyContent string
if authType == "key" || authType == "both" {
keyPath := readLine("Path to private key (~/.ssh/id_rsa): ")
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
group := readLine("Group (optional): ")
// Get tags
tagsInput := readLine("Tags (comma-separated, optional): ")
var tags []string
if tagsInput != "" {
tags = strings.Split(tagsInput, ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
}
}
// Get notes
notes := readLine("Notes (optional): ")
// 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 := newStorage(cfg)
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
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: `Generate shell completion script for hostkeeper.
To load completions:
Bash:
$ source <(hostkeeper completion bash)
# To load completions for each session, execute once:
# Linux:
$ hostkeeper completion bash > /etc/bash_completion.d/hostkeeper
# macOS:
$ hostkeeper completion bash > /usr/local/etc/bash_completion.d/hostkeeper
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ hostkeeper completion zsh > "${fpath[1]}/_hostkeeper"
# You will need to start a new shell for this setup to take effect.
fish:
$ hostkeeper completion fish | source
# To load completions for each session, execute once:
$ hostkeeper completion fish > ~/.config/fish/completions/hostkeeper.fish
PowerShell:
PS> hostkeeper completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> hostkeeper completion powershell > hostkeeper.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactValidArgs(1),
Run: func(cmd *cobra.Command, args []string) {
switch args[0] {
case "bash":
_ = cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
_ = cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var (
connectTimeout int
connectNative bool
)
// connectCmd represents the connect command
var connectCmd = &cobra.Command{
Use: "connect <host-name-or-id>",
Short: "Connect to a saved SSH host",
Long: `Connect to a saved SSH host using stored credentials.
Examples:
# Connect to a host by name
hostkeeper connect myserver
# Connect with a specific timeout
hostkeeper connect myserver --timeout 60
# Use native system SSH instead of Go SSH client
hostkeeper connect myserver --native`,
Args: cobra.ExactArgs(1),
RunE: runConnect,
}
func init() {
connectCmd.Flags().IntVar(&connectTimeout, "timeout", 30, "Connection timeout in seconds")
connectCmd.Flags().BoolVar(&connectNative, "native", false, "Use native system SSH instead of Go SSH client")
rootCmd.AddCommand(connectCmd)
}
func runConnect(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)
}
}
// Initialize storage
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
ctx := context.Background()
// Find host by name or ID
host, err := findHost(ctx, store, hostIdentifier)
if err != nil {
return err
}
fmt.Printf("Connecting to %s (%s@%s:%d)...\n", host.Name, host.Username, host.Hostname, host.Port)
// Choose connection method
if connectNative {
return connectWithNativeSSH(host)
}
return connectDirectSSH(host)
}
// findHost finds a host by ID first, then by name
func findHost(ctx context.Context, store storage.Storage, identifier string) (*models.Host, error) {
// Try to find by ID first
host, err := store.GetHost(ctx, identifier)
if err == nil {
return host, nil
}
// Try to find by name
hosts, err := store.ListHosts(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list hosts: %w", err)
}
for _, h := range hosts {
if h.Name == identifier {
return h, nil
}
}
// Try to find by hostname
for _, h := range hosts {
if h.Hostname == identifier {
return h, nil
}
}
// Try to find by ID prefix (short ID match)
for _, h := range hosts {
if len(h.ID) >= 8 && h.ID[:8] == identifier {
return h, nil
}
}
// Host not found, provide helpful error
msg := fmt.Sprintf("host '%s' not found. Use 'hostkeeper list' to see available hosts", identifier)
if strings.Contains(identifier, " ") {
msg += fmt.Sprintf("\nHint: if the host name contains spaces, quote it: connect \"%s\"", identifier)
}
return nil, fmt.Errorf("%s", msg)
}
// connectWithNativeSSH uses the system SSH client
func connectWithNativeSSH(host *models.Host) error {
sshArgs := buildSSHArgs(host)
cmd := exec.Command("ssh", sshArgs...)
// Set up standard I/O for interactive session
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Execute SSH command
if err := cmd.Run(); err != nil {
return errors.HandleSSHError(err)
}
return nil
}
// connectDirectSSH uses Go SSH client
func connectDirectSSH(host *models.Host) error {
timeout := time.Duration(connectTimeout) * time.Second
client := ssh.NewClient(host, timeout)
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
return err
}
defer client.Close()
fmt.Printf("Connected to %s (%s@%s:%d)\n", host.Name, host.Username, host.Hostname, host.Port)
if err := client.Shell(); err != nil {
return fmt.Errorf("shell session failed: %w", err)
}
return nil
}
// buildSSHArgs builds SSH command arguments for the system SSH client
func buildSSHArgs(host *models.Host) []string {
var args []string
// Add port if not default
if host.Port != 22 && host.Port != 0 {
args = append(args, "-p", fmt.Sprintf("%d", host.Port))
}
// Add connection string
connectionString := fmt.Sprintf("%s@%s", host.Username, host.Hostname)
args = append(args, connectionString)
return args
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
func TestConnectCommandExists(t *testing.T) {
if connectCmd == nil {
t.Fatal("connectCmd should not be nil")
}
if connectCmd.Use != "connect <host-name-or-id>" {
t.Errorf("expected Use 'connect <host-name-or-id>', got '%s'", connectCmd.Use)
}
if connectCmd.Short == "" {
t.Error("Short description should not be empty")
}
}
func TestConnectCommandFlags(t *testing.T) {
expectedFlags := []string{"timeout", "native"}
for _, flagName := range expectedFlags {
if connectCmd.Flags().Lookup(flagName) == nil {
t.Errorf("flag '%s' should be defined", flagName)
}
}
}
func TestConnectArgs(t *testing.T) {
if connectCmd.Args == nil {
t.Error("Args validator should not be nil")
}
}
func TestBuildSSHArgs(t *testing.T) {
tests := []struct {
name string
host *models.Host
want []string
}{
{
name: "default port 22",
host: &models.Host{
Name: "test",
Hostname: "192.168.1.1",
Port: 22,
Username: "admin",
},
want: []string{"admin@192.168.1.1"},
},
{
name: "non-default port 2222",
host: &models.Host{
Name: "test",
Hostname: "192.168.1.1",
Port: 2222,
Username: "admin",
},
want: []string{"-p", "2222", "admin@192.168.1.1"},
},
{
name: "key auth with KeyID",
host: &models.Host{
Name: "test",
Hostname: "10.0.0.1",
Port: 22,
Username: "root",
Auth: models.AuthConfig{
Type: "key",
KeyID: "~/.ssh/id_rsa",
},
},
want: []string{"root@10.0.0.1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSSHArgs(tt.host)
if len(got) != len(tt.want) {
t.Errorf("buildSSHArgs() = %v, want %v", got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("buildSSHArgs() = %v, want %v", got, tt.want)
return
}
}
})
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"fmt"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
)
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 := newStorage(cfg)
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 := newStorage(cfg)
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")
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var (
exportFormat string
exportIncludeKeys bool
)
// exportCmd represents the export command
var exportCmd = &cobra.Command{
Use: "export [filename]",
Short: "Export hosts and credentials to file",
Long: `Export all saved hosts, SSH keys, snippets to a file for backup or transfer.
Examples:
# Export to default file
hostkeeper export my-backup
# Export with .json extension
hostkeeper export my-backup.json`,
Args: cobra.ExactArgs(1),
RunE: runExport,
}
func init() {
exportCmd.Flags().StringVar(&exportFormat, "format", "json", "Export format (json)")
exportCmd.Flags().BoolVar(&exportIncludeKeys, "include-keys", true, "Include SSH keys in export")
rootCmd.AddCommand(exportCmd)
}
func runExport(cmd *cobra.Command, args []string) error {
filename := args[0]
if filepath.Ext(filename) != ".json" {
filename = filename + ".json"
}
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()
data, err := store.ExportData(ctx)
if err != nil {
return fmt.Errorf("failed to export data: %w", err)
}
jsonBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal export data: %w", err)
}
if err := os.WriteFile(filename, jsonBytes, 0600); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Export successful!\n")
fmt.Printf(" File: %s\n", filename)
fmt.Printf(" Hosts: %d\n", len(data.Hosts))
fmt.Printf(" Keys: %d\n", len(data.KeyPairs))
fmt.Printf(" Snippets: %d\n", len(data.Snippets))
fmt.Printf(" Size: %.2f KB\n", float64(len(jsonBytes))/1024)
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var (
importMergeStrategy string
importDryRun bool
)
// importCmd represents the import command
var importCmd = &cobra.Command{
Use: "import <filename>",
Short: "Import hosts and credentials from file",
Long: `Import hosts, SSH keys, snippets from a previously exported file.
Merge strategies:
replace Replace all existing data with imported data
merge Keep existing data, add only new items
Examples:
# Import with merge (default)
hostkeeper import my-backup.json
# Import replacing all existing data
hostkeeper import my-backup.json --strategy replace
# Preview without importing
hostkeeper import my-backup.json --dry-run`,
Args: cobra.ExactArgs(1),
RunE: runImport,
}
func init() {
importCmd.Flags().StringVar(&importMergeStrategy, "strategy", "merge", "Merge strategy: replace or merge")
importCmd.Flags().BoolVar(&importDryRun, "dry-run", false, "Show what would be imported without actually importing")
rootCmd.AddCommand(importCmd)
}
func runImport(cmd *cobra.Command, args []string) error {
filename := args[0]
if _, err := os.Stat(filename); os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", filename)
}
jsonBytes, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read import file: %w", err)
}
var data storage.ExportData
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return fmt.Errorf("failed to parse import file: %w", err)
}
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()
if importDryRun {
return previewImport(&data, filename)
}
// Map strategy string to MergeStrategy type
var strategy storage.MergeStrategy
switch importMergeStrategy {
case "replace":
strategy = storage.MergeStrategyReplace
case "merge":
strategy = storage.MergeStrategyMerge
default:
return fmt.Errorf("invalid strategy: %s (use 'replace' or 'merge')", importMergeStrategy)
}
if err := store.ImportData(ctx, &data, strategy); err != nil {
return fmt.Errorf("failed to import data: %w", err)
}
hosts, _ := store.ListHosts(ctx)
keys, _ := store.ListKeyPairs(ctx)
snippets, _ := store.ListSnippets(ctx)
fmt.Printf("Import successful!\n")
fmt.Printf(" Strategy: %s\n", importMergeStrategy)
fmt.Printf(" Total Hosts: %d\n", len(hosts))
fmt.Printf(" Total Keys: %d\n", len(keys))
fmt.Printf(" Total Snippets: %d\n", len(snippets))
return nil
}
func previewImport(data *storage.ExportData, filename string) error {
fmt.Println("Import Preview (Dry Run)")
fmt.Println("------------------------")
fmt.Printf("File: %s\n", filename)
fmt.Printf("Hosts: %d\n", len(data.Hosts))
fmt.Printf("Keys: %d\n", len(data.KeyPairs))
fmt.Printf("Snippets: %d\n", len(data.Snippets))
fmt.Printf("Strategy: %s\n\n", importMergeStrategy)
fmt.Println("To perform the import, run:")
fmt.Printf(" hostkeeper import %s --strategy %s\n", filename, importMergeStrategy)
return nil
}
+193
View File
@@ -0,0 +1,193 @@
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"
)
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 := newStorage(cfg)
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 shortID(id string) string {
if len(id) >= 8 {
return id[:8]
}
return id
}
func outputTable(hosts []*models.Host) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tHOSTNAME\tPORT\tUSER\tGROUP\tAUTH\tTAGS")
fmt.Fprintln(w, "--\t────\t────────\t────\t────\t─────\t────\t────")
for _, h := range hosts {
tags := strings.Join(h.Tags, ", ")
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
shortID(h.ID),
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","short_id":"%s","name":"%s","hostname":"%s","port":%d,"username":"%s","group":"%s","auth_type":"%s"}`,
h.ID, shortID(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)
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
)
var (
version = "dev"
buildTime = "unknown"
)
func main() {
// SSH_ASKPASS support: hostkeeper askpass
// Called by the SSH_ASKPASS script we create for key passphrases.
if len(os.Args) == 2 && os.Args[1] == "askpass" {
fmt.Print(os.Getenv("HK_PASSPHRASE"))
return
}
if err := Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
var (
cfgFile string
appCfg *config.Config
verbose int
debug bool
passwordFlag string
)
var rootCmd = &cobra.Command{
Use: "hostkeeper",
Short: "Cross-platform SSH/SFTP management tool",
Long: `Hostkeeper - Cross-platform SSH/SFTP Management Tool
A comprehensive SSH/SFTP management tool with secure credential storage,
host management, and cross-device sync capabilities.
Quick Start:
hostkeeper add myserver --host 192.168.1.10 --user admin
hostkeeper list
hostkeeper connect myserver
For more information, visit the project repository.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Initialize configuration
cfg, err := config.New()
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
appCfg = cfg
return nil
},
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is platform-specific app dir)")
rootCmd.PersistentFlags().CountVarP(&verbose, "verbose", "v", "verbose output (-v for info, -vv for debug)")
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug mode")
rootCmd.PersistentFlags().StringVar(&passwordFlag, "password", "", "master password for encrypted storage")
rootCmd.AddCommand(versionCmd)
}
// initConfig reads in config file and ENV variables if set
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
// Use platform-specific config directory
cfg, err := config.New()
if err != nil {
return
}
viper.AddConfigPath(cfg.GetConfigDir())
viper.SetConfigType("json")
viper.SetConfigName("config")
}
viper.AutomaticEnv()
// Read config file (ignore if not found for first run)
_ = viper.ReadInConfig()
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number",
Long: `Print the version and build information for HostKeeper.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("hostkeeper %s (built: %s)\n", version, buildTime)
},
}
// Execute runs the root command
func Execute() error {
return rootCmd.Execute()
}
// newStorage creates a new JSONStorage with the password flag applied
func newStorage(cfg *config.Config) (*storage.JSONStorage, error) {
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return nil, err
}
if passwordFlag != "" {
store.SetPassword(passwordFlag)
}
return store, nil
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/knownhosts"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
)
var encryptFlag bool
// tuiCmd represents the tui command
var tuiCmd = &cobra.Command{
Use: "tui",
Short: "Launch terminal user interface",
Long: `Launch an interactive terminal user interface for managing SSH hosts and connections.`,
RunE: runTUI,
}
func init() {
rootCmd.AddCommand(tuiCmd)
tuiCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "Enable AES-256-GCM encryption for sensitive data")
}
func runTUI(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)
}
}
store, err := storage.NewJSONStorage(cfg.GetDataDir())
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Initialize known_hosts
kh, err := knownhosts.New(cfg.GetDataDir())
if err != nil {
fmt.Printf("Warning: failed to load known_hosts: %v\n", err)
}
// Auto-detect encrypted data files
needPassword := encryptFlag || store.IsDataEncrypted()
model := tui.New()
model.SetDataDir(cfg.GetDataDir())
if kh != nil {
model.SetKnownHosts(kh)
}
if needPassword {
// Show password prompt first — hosts will be loaded after password is set
model.ShowEncryptPrompt()
} else {
// No encryption — load hosts normally
ctx := context.Background()
hosts, err := store.ListHosts(ctx)
if err != nil {
return fmt.Errorf("failed to load hosts: %w", err)
}
model.LoadHosts(hosts)
}
p := tea.NewProgram(model)
model.SetProgram(p)
if _, err := p.Run(); err != nil {
return fmt.Errorf("failed to run TUI: %w", err)
}
return nil
}