Merge pull request #39 from Gu1llaum-3/dev

release: v1.10.0 - Remote execution, ProxyCommand and Shell completion
This commit is contained in:
Guillaume Archambault
2026-01-04 22:46:48 +01:00
committed by GitHub
17 changed files with 858 additions and 113 deletions

View File

@@ -44,7 +44,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
- **🔄 Automatic Backups** - Backup configurations automatically before changes
- **✅ Validation** - Prevent configuration errors with built-in validation
- **🔗 ProxyJump Support** - Secure connection tunneling through bastion hosts
- **🔗 ProxyJump/ProxyCommand Support** - Secure connection tunneling through bastion hosts
- **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts
- **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **⚡ Lightweight** - Single binary with no dependencies, zero configuration required
@@ -129,6 +129,7 @@ The interactive forms will guide you through configuration:
- **Port** - SSH port (default: 22)
- **Identity File** - Private key path
- **ProxyJump** - Jump server for connection tunneling
- **ProxyCommand** - Jump command for connection tunneling
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization
@@ -228,6 +229,15 @@ sshm
# Connect directly to a specific host (with history tracking)
sshm my-server
# Execute a command on a remote host
sshm my-server uptime
# Execute command with arguments
sshm my-server ls -la /var/log
# Force TTY allocation for interactive commands
sshm -t my-server sudo systemctl restart nginx
# Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config
@@ -265,6 +275,53 @@ sshm --version
sshm --help
```
### Shell Completion
SSHM supports shell completion for host names, making it easy to connect to hosts without typing full names:
```bash
sshm <TAB> # Lists all available hosts
sshm pro<TAB> # Completes to hosts starting with "pro" (e.g., prod-server)
```
**Setup Instructions:**
**Bash:**
```bash
# Enable for current session
source <(sshm completion bash)
# Enable permanently (add to ~/.bashrc)
echo 'source <(sshm completion bash)' >> ~/.bashrc
```
**Zsh:**
```bash
# Enable for current session
source <(sshm completion zsh)
# Enable permanently (add to ~/.zshrc)
echo 'source <(sshm completion zsh)' >> ~/.zshrc
```
**Fish:**
```bash
# Enable for current session
sshm completion fish | source
# Enable permanently
sshm completion fish > ~/.config/fish/completions/sshm.fish
```
**PowerShell:**
```powershell
# Enable for current session
sshm completion powershell | Out-String | Invoke-Expression
# Enable permanently (add to your PowerShell profile)
Add-Content $PROFILE 'sshm completion powershell | Out-String | Invoke-Expression'
```
### Direct Host Connection
SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow:
@@ -285,6 +342,33 @@ sshm web-01
- **Error handling** - Clear messages if host doesn't exist or configuration issues
- **Config file support** - Works with custom config files using `-c` flag
### Remote Command Execution
Execute commands on remote hosts without opening an interactive shell:
```bash
# Execute a single command
sshm prod-server uptime
# Execute command with arguments
sshm prod-server ls -la /var/log
# Check disk usage
sshm prod-server df -h
# View logs (pipe to local commands)
sshm prod-server 'cat /var/log/nginx/access.log' | grep 404
# Force TTY allocation for interactive commands (sudo, vim, etc.)
sshm -t prod-server sudo systemctl restart nginx
```
**Features:**
- **Exit code propagation** - Remote command exit codes are passed through
- **TTY support** - Use `-t` flag for commands requiring terminal interaction
- **Pipe-friendly** - Output can be piped to local commands for processing
- **History tracking** - Command executions are recorded in connection history
### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
@@ -504,6 +588,7 @@ Host backend-prod
User app
Port 22
ProxyJump bastion.company.com
ProxyCommand ssh -W %h:%p Jumphost
IdentityFile ~/.ssh/production_key
Compression yes
ServerAliveInterval 300
@@ -520,6 +605,7 @@ SSHM supports all standard SSH configuration options:
- `Port` - SSH port number
- `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`)
- `Tags` - Custom tags (SSHM extension)
**Additional SSH Options:**

60
cmd/completion.go Normal file
View File

@@ -0,0 +1,60 @@
package cmd
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 sshm.
To load completions:
Bash:
$ source <(sshm completion bash)
# To load completions for each session, add to your ~/.bashrc:
# echo 'source <(sshm completion bash)' >> ~/.bashrc
Zsh:
$ source <(sshm completion zsh)
# To load completions for each session, add to your ~/.zshrc:
# echo 'source <(sshm completion zsh)' >> ~/.zshrc
Fish:
$ sshm completion fish | source
# To load completions for each session:
$ sshm completion fish > ~/.config/fish/completions/sshm.fish
PowerShell:
PS> sshm completion powershell | Out-String | Invoke-Expression
# To load completions for each session, add to your PowerShell profile:
# Add-Content $PROFILE 'sshm completion powershell | Out-String | Invoke-Expression'
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletionV2(os.Stdout, true)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
func init() {
RootCmd.AddCommand(completionCmd)
}

285
cmd/completion_test.go Normal file
View File

@@ -0,0 +1,285 @@
package cmd
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCompletionCommand(t *testing.T) {
if completionCmd.Use != "completion [bash|zsh|fish|powershell]" {
t.Errorf("Expected Use 'completion [bash|zsh|fish|powershell]', got '%s'", completionCmd.Use)
}
if completionCmd.Short != "Generate shell completion script" {
t.Errorf("Expected Short description, got '%s'", completionCmd.Short)
}
}
func TestCompletionCommandValidArgs(t *testing.T) {
expected := []string{"bash", "zsh", "fish", "powershell"}
if len(completionCmd.ValidArgs) != len(expected) {
t.Errorf("Expected %d valid args, got %d", len(expected), len(completionCmd.ValidArgs))
}
for i, arg := range expected {
if completionCmd.ValidArgs[i] != arg {
t.Errorf("Expected ValidArgs[%d] to be '%s', got '%s'", i, arg, completionCmd.ValidArgs[i])
}
}
}
func TestCompletionCommandRegistered(t *testing.T) {
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "completion" {
found = true
break
}
}
if !found {
t.Error("Expected 'completion' command to be registered")
}
}
func TestCompletionBashOutput(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
RootCmd.SetArgs([]string{"completion", "bash"})
err := RootCmd.Execute()
w.Close()
os.Stdout = oldStdout
if err != nil {
t.Errorf("Expected no error for bash completion, got %v", err)
}
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if !strings.Contains(output, "bash completion") || !strings.Contains(output, "sshm") {
t.Error("Bash completion output should contain bash completion markers and sshm")
}
}
func TestCompletionZshOutput(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
RootCmd.SetArgs([]string{"completion", "zsh"})
err := RootCmd.Execute()
w.Close()
os.Stdout = oldStdout
if err != nil {
t.Errorf("Expected no error for zsh completion, got %v", err)
}
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if !strings.Contains(output, "compdef") || !strings.Contains(output, "sshm") {
t.Error("Zsh completion output should contain compdef and sshm")
}
}
func TestCompletionFishOutput(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
RootCmd.SetArgs([]string{"completion", "fish"})
err := RootCmd.Execute()
w.Close()
os.Stdout = oldStdout
if err != nil {
t.Errorf("Expected no error for fish completion, got %v", err)
}
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if !strings.Contains(output, "complete") || !strings.Contains(output, "sshm") {
t.Error("Fish completion output should contain complete command and sshm")
}
}
func TestCompletionPowershellOutput(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
RootCmd.SetArgs([]string{"completion", "powershell"})
err := RootCmd.Execute()
w.Close()
os.Stdout = oldStdout
if err != nil {
t.Errorf("Expected no error for powershell completion, got %v", err)
}
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if !strings.Contains(output, "Register-ArgumentCompleter") || !strings.Contains(output, "sshm") {
t.Error("PowerShell completion output should contain Register-ArgumentCompleter and sshm")
}
}
func TestCompletionInvalidShell(t *testing.T) {
RootCmd.SetArgs([]string{"completion", "invalid"})
err := RootCmd.Execute()
if err == nil {
t.Error("Expected error for invalid shell type")
}
}
func TestCompletionNoArgs(t *testing.T) {
RootCmd.SetArgs([]string{"completion"})
err := RootCmd.Execute()
if err == nil {
t.Error("Expected error when no shell type provided")
}
}
func TestValidArgsFunction(t *testing.T) {
if RootCmd.ValidArgsFunction == nil {
t.Fatal("Expected ValidArgsFunction to be set on RootCmd")
}
}
func TestValidArgsFunctionWithSSHConfig(t *testing.T) {
tmpDir := t.TempDir()
testConfigFile := filepath.Join(tmpDir, "config")
sshConfig := `Host prod-server
HostName 192.168.1.1
User admin
Host dev-server
HostName 192.168.1.2
User developer
Host staging-db
HostName 192.168.1.3
User dbadmin
`
err := os.WriteFile(testConfigFile, []byte(sshConfig), 0600)
if err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
originalConfigFile := configFile
defer func() { configFile = originalConfigFile }()
configFile = testConfigFile
tests := []struct {
name string
toComplete string
args []string
wantCount int
wantHosts []string
}{
{
name: "empty prefix returns all hosts",
toComplete: "",
args: []string{},
wantCount: 3,
wantHosts: []string{"prod-server", "dev-server", "staging-db"},
},
{
name: "prefix filters hosts",
toComplete: "prod",
args: []string{},
wantCount: 1,
wantHosts: []string{"prod-server"},
},
{
name: "prefix case insensitive",
toComplete: "DEV",
args: []string{},
wantCount: 1,
wantHosts: []string{"dev-server"},
},
{
name: "no match returns empty",
toComplete: "nonexistent",
args: []string{},
wantCount: 0,
wantHosts: []string{},
},
{
name: "already has host arg returns nothing",
toComplete: "",
args: []string{"existing-host"},
wantCount: 0,
wantHosts: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
completions, directive := RootCmd.ValidArgsFunction(RootCmd, tt.args, tt.toComplete)
if len(completions) != tt.wantCount {
t.Errorf("Expected %d completions, got %d: %v", tt.wantCount, len(completions), completions)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("Expected ShellCompDirectiveNoFileComp, got %v", directive)
}
for _, wantHost := range tt.wantHosts {
found := false
for _, comp := range completions {
if comp == wantHost {
found = true
break
}
}
if !found {
t.Errorf("Expected completion '%s' not found in %v", wantHost, completions)
}
}
})
}
}
func TestValidArgsFunctionWithNonExistentConfig(t *testing.T) {
tmpDir := t.TempDir()
nonExistentConfig := filepath.Join(tmpDir, "nonexistent")
originalConfigFile := configFile
defer func() { configFile = originalConfigFile }()
configFile = nonExistentConfig
completions, directive := RootCmd.ValidArgsFunction(RootCmd, []string{}, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("Expected ShellCompDirectiveNoFileComp for non-existent config, got %v", directive)
}
if len(completions) != 0 {
t.Errorf("Expected empty completions for non-existent config, got %v", completions)
}
}

View File

@@ -24,33 +24,79 @@ var AppVersion = "dev"
// configFile holds the path to the SSH config file
var configFile string
// forceTTY forces pseudo-TTY allocation for remote commands
var forceTTY bool
// searchMode enables the focus on search mode at startup
var searchMode bool
// RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "sshm [host]",
Use: "sshm [host] [command...]",
Short: "SSH Manager - A modern SSH connection manager",
Long: `SSHM is a modern SSH manager for your terminal.
Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
Running 'sshm <host>' connects directly to the specified host and records the connection in your history.
Running 'sshm <host> <command>' executes the command on the remote host and returns the output.
You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts.
Hosts are read from your ~/.ssh/config file by default.`,
Hosts are read from your ~/.ssh/config file by default.
Examples:
sshm # Open interactive TUI
sshm prod-server # Connect to host interactively
sshm prod-server uptime # Execute 'uptime' on remote host
sshm prod-server ls -la /var # Execute command with arguments
sshm -t prod-server sudo reboot # Force TTY for interactive commands`,
Version: AppVersion,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves
// ValidArgsFunction provides shell completion for host names
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Only complete the first positional argument (host name)
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var hosts []config.SSHHost
var err error
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var completions []string
toCompleteLower := strings.ToLower(toComplete)
for _, host := range hosts {
if strings.HasPrefix(strings.ToLower(host.Name), toCompleteLower) {
completions = append(completions, host.Name)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
// If no arguments provided, run interactive mode
if len(args) == 0 {
runInteractiveMode()
return nil
}
// If a host name is provided, connect directly
hostName := args[0]
connectToHost(hostName)
var remoteCommand []string
if len(args) > 1 {
remoteCommand = args[1:]
}
connectToHost(hostName, remoteCommand)
return nil
},
}
@@ -97,13 +143,12 @@ func runInteractiveMode() {
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil {
if err := ui.RunInteractiveMode(hosts, configFile, searchMode, AppVersion); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
func connectToHost(hostName string) {
// Quick check if host exists without full parsing (optimized for connection)
func connectToHost(hostName string, remoteCommand []string) {
var hostFound bool
var err error
@@ -123,45 +168,42 @@ func connectToHost(hostName string) {
os.Exit(1)
}
// Record the connection in history
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else {
err = historyManager.RecordConnection(hostName)
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
// Build and execute the SSH command
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
var args []string
if configFile != "" {
args = append(args, "-F", configFile)
}
if forceTTY {
args = append(args, "-t")
}
args = append(args, hostName)
// Note: We don't add RemoteCommand here because if it's configured in SSH config,
// SSH will handle it automatically. Adding it as a command line argument would conflict.
if len(remoteCommand) > 0 {
args = append(args, remoteCommand...)
} else {
fmt.Printf("Connecting to %s...\n", hostName)
}
sshCmd = exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd := exec.Command("ssh", args...)
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
@@ -197,17 +239,13 @@ func getVersionWithUpdateCheck() string {
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
// Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil {
// Check if this is an "unknown command" error and the argument might be a host name
errStr := err.Error()
if strings.Contains(errStr, "unknown command") {
// Extract the command name from the error
parts := strings.Split(errStr, "\"")
if len(parts) >= 2 {
potentialHost := parts[1]
// Try to connect to this as a host
connectToHost(potentialHost)
connectToHost(potentialHost, nil)
return
}
}
@@ -217,8 +255,9 @@ func Execute() {
}
func init() {
// Add the config file flag
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
RootCmd.Flags().BoolVarP(&forceTTY, "tty", "t", false, "Force pseudo-TTY allocation (useful for interactive remote commands)")
RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup")
// Set custom version template with update check
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())

View File

@@ -7,9 +7,8 @@ import (
)
func TestRootCommand(t *testing.T) {
// Test that the root command is properly configured
if RootCmd.Use != "sshm [host]" {
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
if RootCmd.Use != "sshm [host] [command...]" {
t.Errorf("Expected Use 'sshm [host] [command...]', got '%s'", RootCmd.Use)
}
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
@@ -22,10 +21,8 @@ func TestRootCommand(t *testing.T) {
}
func TestRootCommandFlags(t *testing.T) {
// Test that persistent flags are properly configured
flags := RootCmd.PersistentFlags()
// Check config flag
configFlag := flags.Lookup("config")
if configFlag == nil {
t.Error("Expected --config flag to be defined")
@@ -34,6 +31,15 @@ func TestRootCommandFlags(t *testing.T) {
if configFlag.Shorthand != "c" {
t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand)
}
ttyFlag := RootCmd.Flags().Lookup("tty")
if ttyFlag == nil {
t.Error("Expected --tty flag to be defined")
return
}
if ttyFlag.Shorthand != "t" {
t.Errorf("Expected tty flag shorthand 't', got '%s'", ttyFlag.Shorthand)
}
}
func TestRootCommandSubcommands(t *testing.T) {
@@ -103,13 +109,17 @@ func TestExecuteFunction(t *testing.T) {
}
func TestConnectToHostFunction(t *testing.T) {
// Test that connectToHost function exists and can be called
// Note: We can't easily test the actual connection without a valid SSH config
// and without actually connecting to a host, but we can verify the function exists
t.Log("connectToHost function exists and is accessible")
}
// The function will handle errors internally (like host not found)
// We don't want to actually test the SSH connection in unit tests
func TestRemoteCommandUsage(t *testing.T) {
if !strings.Contains(RootCmd.Long, "command") {
t.Error("Long description should mention remote command execution")
}
if !strings.Contains(RootCmd.Long, "uptime") {
t.Error("Long description should include command examples")
}
}
func TestRunInteractiveModeFunction(t *testing.T) {

View File

@@ -205,6 +205,7 @@ func outputJSON(hosts []config.SSHHost) {
fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port))
fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity))
fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump))
fmt.Printf(" \"proxy_command\": \"%s\",\n", escapeJSON(host.ProxyCommand))
fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options))
fmt.Printf(" \"tags\": [")
for j, tag := range host.Tags {

View File

@@ -19,11 +19,13 @@ type SSHHost struct {
Port string
Identity string
ProxyJump string
ProxyCommand string
Options string
RemoteCommand string // Command to execute after SSH connection
RequestTTY string // Request TTY (yes, no, force, auto)
Tags []string
SourceFile string // Path to the config file where this host is defined
LineNumber int // Line number in the source file where this host block starts (1-indexed)
// Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field
@@ -211,8 +213,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
var currentHost *SSHHost
var pendingTags []string
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
// Ignore empty lines
@@ -279,8 +283,12 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
hostNames := strings.Fields(value)
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
// Also remove surrounding quotes from host names
var validHostNames []string
for _, hostName := range hostNames {
// Remove surrounding double quotes if present
hostName = strings.Trim(hostName, `"`)
if !strings.ContainsAny(hostName, "*?") {
validHostNames = append(validHostNames, hostName)
}
@@ -299,6 +307,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
Port: "22", // Default port
Tags: pendingTags, // Assign pending tags to this host
SourceFile: absPath, // Track which file this host comes from
LineNumber: lineNumber, // Track the line number where Host declaration starts
}
// Store additional host names for later processing
@@ -328,6 +337,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil {
currentHost.ProxyJump = value
}
case "proxycommand":
if currentHost != nil {
currentHost.ProxyCommand = value
}
case "remotecommand":
if currentHost != nil {
currentHost.RemoteCommand = value
@@ -613,6 +626,13 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
}
}
if host.ProxyCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" ProxyCommand=%s\n", host.ProxyCommand))
if err != nil {
return err
}
}
if host.RemoteCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand))
if err != nil {
@@ -646,13 +666,34 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
}
// ParseSSHOptionsFromCommand converts SSH command line options to config format
// Input: "-o Compression=yes -o ServerAliveInterval=60"
// Input: "-o Compression=yes -o ServerAliveInterval=60" or "ForwardX11 true" or "Compression yes"
// Output: "Compression yes\nServerAliveInterval 60"
func ParseSSHOptionsFromCommand(options string) string {
if options == "" {
return ""
}
options = strings.TrimSpace(options)
// If it doesn't contain -o, assume it's already in config format
if !strings.Contains(options, "-o") {
// Just normalize spaces and ensure newlines between options
lines := strings.Split(options, "\n")
var result []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Normalize spacing (replace multiple spaces with single space)
parts := strings.Fields(line)
if len(parts) > 0 {
result = append(result, strings.Join(parts, " "))
}
}
return strings.Join(result, "\n")
}
var result []string
parts := strings.Split(options, "-o")
@@ -678,6 +719,12 @@ func FormatSSHOptionsForCommand(options string) string {
return ""
}
// If already in command format (starts with -o), return as is
trimmed := strings.TrimSpace(options)
if strings.HasPrefix(trimmed, "-o ") {
return trimmed
}
var result []string
lines := strings.Split(options, "\n")
@@ -853,6 +900,9 @@ func quickHostSearchInFile(hostName string, configPath string, processedFiles ma
// Check if our target host is in this Host declaration
for _, candidateHostName := range hostNames {
// Remove surrounding double quotes if present
candidateHostName = strings.Trim(candidateHostName, `"`)
// Skip hosts with wildcards (*, ?) as they are typically patterns
if !strings.ContainsAny(candidateHostName, "*?") && candidateHostName == hostName {
return true, nil // Found the host!
@@ -1044,6 +1094,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
@@ -1098,6 +1151,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
@@ -1188,6 +1244,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
@@ -1242,6 +1301,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
}
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
@@ -1283,11 +1345,21 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
// DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error {
return DeleteSSHHostV2(hostName)
return DeleteSSHHostV2(hostName, 0) // Legacy: without line number
}
// DeleteSSHHostWithLine deletes a specific SSH host by name and line number
func DeleteSSHHostWithLine(host SSHHost) error {
return DeleteSSHHostFromFileWithLine(host.Name, host.SourceFile, host.LineNumber)
}
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
func DeleteSSHHostFromFile(hostName, configPath string) error {
return DeleteSSHHostFromFileWithLine(hostName, configPath, 0) // Legacy: without line number
}
// DeleteSSHHostFromFileWithLine deletes an SSH host from a specific config file at a specific line
func DeleteSSHHostFromFileWithLine(hostName, configPath string, targetLineNumber int) error {
configMutex.Lock()
defer configMutex.Unlock()
@@ -1314,11 +1386,13 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
hostFound := false
for i < len(lines) {
currentLineNumber := i + 1 // Convert 0-indexed to 1-indexed
line := strings.TrimSpace(lines[i])
// Check for tags comment followed by Host
if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
nextLineNumber := i + 2 // The Host line is at i+1, so its 1-indexed number is i+2
// Check if this is a Host line that contains our target host
if strings.HasPrefix(nextLine, "Host ") {
@@ -1334,7 +1408,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
}
}
if targetHostIndex != -1 {
// Only proceed if:
// 1. We found the host name
// 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match
if targetHostIndex != -1 && (targetLineNumber == 0 || nextLineNumber == targetLineNumber) {
hostFound = true
if isMultiHost && len(hostNames) > 1 {
@@ -1372,7 +1449,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++
}
continue
// Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else {
// Single host or last host in multi-host block, delete entire block
// Skip tags comment and Host line
@@ -1388,7 +1470,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++
}
continue
// Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
}
}
}
@@ -1408,7 +1495,10 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
}
}
if targetHostIndex != -1 {
// Only proceed if:
// 1. We found the host name
// 2. Either no line number was specified (targetLineNumber == 0) OR the line numbers match
if targetHostIndex != -1 && (targetLineNumber == 0 || currentLineNumber == targetLineNumber) {
hostFound = true
if isMultiHost && len(hostNames) > 1 {
@@ -1443,7 +1533,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++
}
continue
// Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else {
// Single host, delete entire block
// Skip Host line
@@ -1459,7 +1554,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++
}
continue
// Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
}
}
}
@@ -1542,15 +1642,15 @@ func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
}
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
func DeleteSSHHostV2(hostName string) error {
func DeleteSSHHostV2(hostName string, targetLineNumber int) error {
// Find the host to determine which file it's in
existingHost, err := FindHostInAllConfigs(hostName)
if err != nil {
return err
}
// Delete the host from its source file
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
// Delete the host from its source file using line number if provided
return DeleteSSHHostFromFileWithLine(hostName, existingHost.SourceFile, targetLineNumber)
}
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
@@ -1742,6 +1842,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
}
if commonProperties.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand)
}
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
@@ -1828,6 +1931,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
}
if commonProperties.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand)
}
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}

View File

@@ -1694,3 +1694,89 @@ Host production-server
}
}
}
func TestParseSSHConfigWithQuotedHostNames(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `# Test hosts with quoted names (issue #32)
Host "my-host-name-01"
HostName my-host-name-01.cwd.pub.domain.net
Port 2222
User my_user
Host "qa-test-vm"
HostName qa-test-vm.example.com
User guillaume
Port 22
Host normal-host
HostName normal.example.com
User testuser
Host "quoted1" "quoted2"
HostName multi.example.com
User multiuser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get 5 hosts: my-host-name-01, qa-test-vm, normal-host, quoted1, quoted2
// All without quotes
expectedHosts := map[string]struct{}{
"my-host-name-01": {},
"qa-test-vm": {},
"normal-host": {},
"quoted1": {},
"quoted2": {},
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %q", host.Name)
}
}
hostMap := make(map[string]SSHHost)
for _, host := range hosts {
// Verify no quotes in host names
if strings.Contains(host.Name, `"`) {
t.Errorf("Host name %q still contains quotes", host.Name)
}
hostMap[host.Name] = host
}
for expectedHostName := range expectedHosts {
if _, found := hostMap[expectedHostName]; !found {
t.Errorf("Expected host %q not found", expectedHostName)
}
}
// Verify specific host details
if host, found := hostMap["my-host-name-01"]; found {
if host.Hostname != "my-host-name-01.cwd.pub.domain.net" {
t.Errorf("Host my-host-name-01 has wrong hostname: %q", host.Hostname)
}
if host.Port != "2222" {
t.Errorf("Host my-host-name-01 has wrong port: %q", host.Port)
}
if host.User != "my_user" {
t.Errorf("Host my-host-name-01 has wrong user: %q", host.User)
}
}
if host, found := hostMap["qa-test-vm"]; found {
if host.Hostname != "qa-test-vm.example.com" {
t.Errorf("Host qa-test-vm has wrong hostname: %q", host.Hostname)
}
}
}

View File

@@ -49,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
inputs := make([]textinput.Model, 11)
// Name input
inputs[nameInput] = textinput.New()
@@ -91,6 +91,12 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50
// ProxyCommand input
inputs[proxyCommandInput] = textinput.New()
inputs[proxyCommandInput].Placeholder = "ssh -W %h:%p Jumphost"
inputs[proxyCommandInput].CharLimit = 200
inputs[proxyCommandInput].Width = 50
// SSH Options input
inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
@@ -138,9 +144,10 @@ const (
portInput
identityInput
proxyJumpInput
proxyCommandInput
optionsInput
tagsInput
// Advanced tab inputs
optionsInput
remoteCommandInput
requestTTYInput
)
@@ -229,11 +236,11 @@ func (m *addFormModel) getFirstInputForTab(tab int) int {
func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab {
case tabGeneral:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput}
case tabAdvanced:
return []int{optionsInput, remoteCommandInput, requestTTYInput}
default:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput}
}
}
@@ -275,11 +282,29 @@ func (m *addFormModel) handleNavigation(key string) tea.Cmd {
currentPos++
}
// Wrap around within current tab
// Handle transitions between tabs
if currentPos >= len(currentTabInputs) {
currentPos = 0
// Move to next tab
if m.currentTab == tabGeneral {
// Move to advanced tab
m.currentTab = tabAdvanced
m.focused = m.getFirstInputForTab(tabAdvanced)
return m.updateFocus()
} else {
// Wrap around to first field of current tab
currentPos = 0
}
} else if currentPos < 0 {
currentPos = len(currentTabInputs) - 1
// Move to previous tab
if m.currentTab == tabAdvanced {
// Move to general tab
m.currentTab = tabGeneral
currentTabInputs = m.getInputsForCurrentTab()
currentPos = len(currentTabInputs) - 1
} else {
// Wrap around to last field of current tab
currentPos = len(currentTabInputs) - 1
}
}
m.focused = currentTabInputs[currentPos]
@@ -401,6 +426,7 @@ func (m *addFormModel) renderGeneralTab() string {
{portInput, "Port"},
{identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"},
{proxyCommandInput, "ProxyCommand"},
{tagsInput, "Tags (comma-separated)"},
}
@@ -489,6 +515,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
proxyCommand := strings.TrimSpace(m.inputs[proxyCommandInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
@@ -526,6 +553,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
Port: port,
Identity: identity,
ProxyJump: proxyJump,
ProxyCommand: proxyCommand,
Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,

View File

@@ -91,7 +91,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
}
}
inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY
inputs := make([]textinput.Model, 10)
// Hostname input
inputs[0] = textinput.New()
@@ -128,37 +128,44 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[4].Width = 30
inputs[4].SetValue(host.ProxyJump)
// Options input
// ProxyCommand input
inputs[5] = textinput.New()
inputs[5].Placeholder = "-o StrictHostKeyChecking=no"
inputs[5].Placeholder = "ssh -W %h:%p Jumphost"
inputs[5].CharLimit = 200
inputs[5].Width = 50
inputs[5].SetValue(host.ProxyCommand)
// Options input
inputs[6] = textinput.New()
inputs[6].Placeholder = "-o StrictHostKeyChecking=no"
inputs[6].CharLimit = 200
inputs[6].Width = 50
if host.Options != "" {
inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options))
inputs[6].SetValue(config.FormatSSHOptionsForCommand(host.Options))
}
// Tags input
inputs[6] = textinput.New()
inputs[6].Placeholder = "production, web, database"
inputs[6].CharLimit = 200
inputs[6].Width = 50
inputs[7] = textinput.New()
inputs[7].Placeholder = "production, web, database"
inputs[7].CharLimit = 200
inputs[7].Width = 50
if len(host.Tags) > 0 {
inputs[6].SetValue(strings.Join(host.Tags, ", "))
inputs[7].SetValue(strings.Join(host.Tags, ", "))
}
// Remote Command input
inputs[7] = textinput.New()
inputs[7].Placeholder = "ls -la, htop, bash"
inputs[7].CharLimit = 300
inputs[7].Width = 70
inputs[7].SetValue(host.RemoteCommand)
inputs[8] = textinput.New()
inputs[8].Placeholder = "ls -la, htop, bash"
inputs[8].CharLimit = 300
inputs[8].Width = 70
inputs[8].SetValue(host.RemoteCommand)
// RequestTTY input
inputs[8] = textinput.New()
inputs[8].Placeholder = "yes, no, force, auto"
inputs[8].CharLimit = 10
inputs[8].Width = 30
inputs[8].SetValue(host.RequestTTY)
inputs[9] = textinput.New()
inputs[9].Placeholder = "yes, no, force, auto"
inputs[9].CharLimit = 10
inputs[9].Width = 30
inputs[9].SetValue(host.RequestTTY)
return &editFormModel{
hostInputs: hostInputs,
@@ -253,19 +260,19 @@ func (m *editFormModel) updateFocus() tea.Cmd {
func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab {
case 0: // General
return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags
return []int{0, 1, 2, 3, 4, 5, 7} // hostname, user, port, identity, proxyjump, proxycommand, tags
case 1: // Advanced
return []int{5, 7, 8} // options, remotecommand, requesttty
return []int{6, 8, 9} // options, remotecommand, requesttty
default:
return []int{0, 1, 2, 3, 4, 6}
return []int{0, 1, 2, 3, 4, 5, 7}
}
}
// getFirstPropertyForTab returns the first property index for a given tab
func (m *editFormModel) getFirstPropertyForTab(tab int) int {
properties := []int{0, 1, 2, 3, 4, 6} // General tab
properties := []int{0, 1, 2, 3, 4, 5, 7} // General tab
if tab == 1 {
properties = []int{5, 7, 8} // Advanced tab
properties = []int{6, 8, 9} // Advanced tab
}
if len(properties) > 0 {
return properties[0]
@@ -580,7 +587,8 @@ func (m *editFormModel) renderEditGeneralTab() string {
{2, "Port"},
{3, "Identity File"},
{4, "Proxy Jump"},
{6, "Tags (comma-separated)"},
{5, "Proxy Command"},
{7, "Tags (comma-separated)"},
}
for _, field := range fields {
@@ -605,9 +613,9 @@ func (m *editFormModel) renderEditAdvancedTab() string {
index int
label string
}{
{5, "SSH Options"},
{7, "Remote Command"},
{8, "Request TTY"},
{6, "SSH Options"},
{8, "Remote Command"},
{9, "Request TTY"},
}
for _, field := range fields {
@@ -678,14 +686,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
}
// Get property values using direct indices
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput
options := config.ParseSSHOptionsFromCommand(strings.TrimSpace(m.inputs[6].Value())) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput
// Set defaults
if port == "" {
@@ -705,7 +714,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
}
// Parse tags
tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput
tagsStr := strings.TrimSpace(m.inputs[7].Value()) // tagsInput
var tags []string
if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
@@ -723,6 +732,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
Port: port,
Identity: identity,
ProxyJump: proxyJump,
ProxyCommand: proxyCommand,
Options: options,
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,

View File

@@ -97,6 +97,7 @@ func (m *infoFormModel) View() string {
{"Port", formatOptionalValue(m.host.Port)},
{"Identity File", formatOptionalValue(m.host.Identity)},
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
{"ProxyCommand", formatOptionalValue(m.host.ProxyCommand)},
{"SSH Options", formatSSHOptions(m.host.Options)},
{"Tags", formatTags(m.host.Tags)},
}

View File

@@ -74,14 +74,14 @@ type Model struct {
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
historyManager *history.HistoryManager
pingManager *connectivity.PingManager
sortMode SortMode
configFile string // Path to the SSH config file
// Application configuration
appConfig *config.AppConfig
appConfig *config.AppConfig
// Version update information
updateInfo *version.UpdateInfo

View File

@@ -16,7 +16,7 @@ import (
)
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) Model {
// Load application configuration
appConfig, err := config.LoadAppConfig()
if err != nil {
@@ -54,6 +54,7 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
height: 24,
ready: false,
viewMode: ViewList,
searchMode: searchMode,
}
// Sort hosts according to the default sort mode
@@ -64,6 +65,9 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 25
if searchMode {
ti.Focus()
}
// Use dynamic column width calculation (will fallback to static if width not available)
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
@@ -147,8 +151,8 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
}
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error {
m := NewModel(hosts, configFile, currentVersion)
func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) error {
m := NewModel(hosts, configFile, searchMode, currentVersion)
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())

View File

@@ -452,7 +452,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.deleteMode {
// Exit delete mode
m.deleteMode = false
m.deleteHost = ""
m.deleteHost = nil
m.table.Focus()
return m, nil
}
@@ -508,15 +508,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else if m.deleteMode {
// Confirm deletion
var err error
if m.configFile != "" {
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile)
} else {
err = config.DeleteSSHHost(m.deleteHost)
if m.deleteHost != nil {
err = config.DeleteSSHHostWithLine(*m.deleteHost)
}
if err != nil {
// Could display an error message here
m.deleteMode = false
m.deleteHost = ""
m.deleteHost = nil
m.table.Focus()
return m, nil
}
@@ -533,7 +531,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if parseErr != nil {
// Could display an error message here
m.deleteMode = false
m.deleteHost = ""
m.deleteHost = nil
m.table.Focus()
return m, nil
}
@@ -548,7 +546,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
m.deleteHost = nil
m.table.Focus()
return m, nil
} else {
@@ -673,11 +671,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d":
if !m.searchMode && !m.deleteMode {
// Delete the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
cursor := m.table.Cursor()
if cursor >= 0 && cursor < len(m.filteredHosts) {
// Get the host at the cursor position (which corresponds to filteredHosts index)
targetHost := &m.filteredHosts[cursor]
m.deleteMode = true
m.deleteHost = hostName
m.deleteHost = targetHost
m.table.Blur()
return m, nil
}

View File

@@ -144,7 +144,11 @@ func (m Model) renderListView() string {
func (m Model) renderDeleteConfirmation() string {
// Remove emojis (uncertain width depending on terminal) to stabilize the frame
title := "DELETE SSH HOST"
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost)
hostName := ""
if m.deleteHost != nil {
hostName = m.deleteHost.Name
}
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", hostName)
action := "This action cannot be undone."
help := "Enter: confirm • Esc: cancel"

View File

@@ -11,6 +11,7 @@ import (
)
// ValidateHostname checks if a hostname is valid
// Accepts regular hostnames, IP addresses, and SSH tokens like %h, %p, %r, %u, %n, %C, %d, %i, %k, %L, %l, %T
func ValidateHostname(hostname string) bool {
if len(hostname) == 0 || len(hostname) > 253 {
return false
@@ -22,7 +23,18 @@ func ValidateHostname(hostname string) bool {
return false
}
hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
// Check if hostname contains SSH tokens (e.g., %h, %p, %r, %u, %n, etc.)
// SSH tokens are documented in ssh_config(5) man page
sshTokenRegex := regexp.MustCompile(`%[hprunCdiklLT]`)
if sshTokenRegex.MatchString(hostname) {
// If it contains SSH tokens, it's a valid SSH config construct
return true
}
// RFC 1123: each label must start with alphanumeric, end with alphanumeric,
// and contain only alphanumeric and hyphens. Labels are 1-63 chars.
// A hostname is one or more labels separated by dots.
hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]|[a-zA-Z0-9]{0,62})?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]|[a-zA-Z0-9]{0,62})?)*$`)
return hostnameRegex.MatchString(hostname)
}

View File

@@ -24,6 +24,19 @@ func TestValidateHostname(t *testing.T) {
{"hostname ending with dot", "example.com.", false},
{"hostname with hyphen", "my-server.com", true},
{"hostname starting with number", "1example.com", true},
{"multiple hyphens and subdomains", "my-host-name-01.cwd.pub.domain.net", true},
{"multiple hyphens", "my-host-name-01", true},
{"complex hostname with hyphens", "server-01-prod.data-center.example.com", true},
{"hostname with consecutive hyphens", "my--server.com", true},
{"single char labels", "a.b.c.d.com", true},
// SSH tokens support (issue #32 comment)
{"SSH token %h", "%h.server.com", true},
{"SSH token %p", "server.com:%p", true},
{"SSH token %r", "%r@server.com", true},
{"SSH token %u", "%u.example.com", true},
{"SSH token %n", "%n.domain.net", true},
{"SSH token %C", "host-%C.com", true},
{"multiple SSH tokens", "%h.%u.server.com", true},
}
for _, tt := range tests {