17 Commits

Author SHA1 Message Date
Guillaume Archambault
891fb2a0f4 Merge pull request #44 from fgbm/main
Fix: connectivity check for hosts using ProxyJump or ProxyCommand
2026-02-22 12:24:42 +01:00
Guillaume Archambault
473b1b6063 Merge pull request #40 from boxpositron/feat/info-command
feat: add info command for JSON host details
2026-02-22 12:19:38 +01:00
Vladislav Chmelyuk
5d0c0ffcf3 refactor: update NewPingManager to accept a config file parameter
- Modified the NewPingManager function to include a configFile argument for better SSH configuration management.
- Updated all relevant tests to reflect the new function signature.
- Enhanced ping functionality to support ProxyJump and ProxyCommand using an external SSH command.
- Adjusted UI initialization to pass the config file to the PingManager.

This change improves flexibility in managing SSH connections and enhances the overall functionality of the ping manager.
2026-02-04 14:17:28 +03:00
David Ibia
7d9b794ceb feat: add info command for JSON host details
Adds a jq-friendly `sshm info` subcommand with host completion and documentation, and makes home directory resolution testable for backup path tests.
2026-01-12 23:53:35 +01:00
Guillaume Archambault
58a9e6f40f Merge pull request #39 from Gu1llaum-3/dev
release: v1.10.0 - Remote execution, ProxyCommand and Shell completion
2026-01-04 22:46:48 +01:00
87f8fb9c6c fix: problems with quotes from Host names and support SSH tokens (issue #32) 2026-01-04 22:21:13 +01:00
8f780e288c fix: use line numbers to prevent deleting all duplicate SSH hosts when removing one 2026-01-04 21:34:09 +01:00
def2b4fa8d fix: correct field mapping in forms and prevent double -o prefix in SSH options 2026-01-04 20:46:11 +01:00
David Ibia
2f9587c8c8 feat: add shell completion for host names (#37)
- Add ValidArgsFunction to RootCmd for dynamic host completion
- Add 'sshm completion' subcommand for bash/zsh/fish/powershell
- Support prefix matching and case-insensitive filtering
- Respect --config flag for custom SSH config files
- Add comprehensive tests for completion functionality
- Document setup instructions in README

Co-authored-by: Guillaume Archambault <67098259+Gu1llaum-3@users.noreply.github.com>
2026-01-04 19:37:52 +01:00
David Ibia
435597f694 feat: add remote command execution support (#36)
Allow executing commands on remote hosts via 'sshm <host> <command>'.
Add -t/--tty flag for forcing TTY allocation on interactive commands.

Co-authored-by: Guillaume Archambault <67098259+Gu1llaum-3@users.noreply.github.com>
2026-01-04 19:24:31 +01:00
66cb80f29c fix(edit): correct Advanced tab field indices mapping 2026-01-04 18:39:39 +01:00
Francesco Raso
e4570e612e fix(add-form): align add/edit form behavior (#28)
Co-authored-by: francesco.raso <francesco.raso@elco.it>
2026-01-04 18:15:57 +01:00
Loïc Dreux
49f01b7494 feat: focus on search input at startup (#27) 2026-01-04 17:59:14 +01:00
Simon Gaufreteau
ce9d678652 feat: ProxyCommand support (#26)
* Add base for ProxyCommand

* Fix crashes with ProxyCommand

* Add ProxyCommand to README

---------

Co-authored-by: Simon Gaufreteau <sgaufret@amazon.lu>
2026-01-04 17:49:04 +01:00
825c534ebe feat(ui): add tabbed forms with height validation
- Implement General/Advanced tabs for add/edit forms
- Add terminal height detection with user-friendly warnings
- Add Ctrl+J/K tab navigation and SSH RemoteCommand/RequestTTY fields
2025-10-13 21:55:08 +02:00
c1457af73a feat: add support for SSH RemoteCommand and RequestTTY in host configuration and TUI forms
- Allow users to specify a RemoteCommand to execute on SSH connection, both via TUI and config file
- Add RequestTTY option (yes, no, force, auto) to host configuration and forms
- Update config parsing and writing to handle new fields
- Improve TUI forms to support editing and adding these options
- Fix edit form standalone mode to allow proper quit/save via keyboard shortcuts
2025-10-12 20:25:20 +02:00
12d97270f0 feat: reorganize release notes 2025-10-10 22:43:06 +02:00
23 changed files with 2163 additions and 275 deletions

View File

@@ -109,24 +109,41 @@ release:
Thank you for downloading SSHM! Thank you for downloading SSHM!
### Installation footer: |
## Installation
**Homebrew (macOS/Linux):** ### Homebrew (macOS/Linux)
```bash ```bash
brew tap Gu1llaum-3/sshm brew tap Gu1llaum-3/sshm
brew install sshm brew install sshm
``` ```
**Manual Installation:** ### Installation Script (Recommended)
Download the appropriate binary for your platform from the assets below. **Unix/Linux/macOS:**
```bash
curl -sSL https://github.com/Gu1llaum-3/sshm/raw/main/install/unix.sh | bash
```
**Windows (PowerShell):**
```powershell
iwr -useb https://github.com/Gu1llaum-3/sshm/raw/main/install/windows.ps1 | iex
```
### Manual Installation
Download the appropriate binary for your platform from the assets above, extract it, and place it in your PATH.
footer: |
## Full Changelog ## Full Changelog
See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}} See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}}
--- ---
📖 **Documentation:** See the updated [README](https://github.com/Gu1llaum-3/sshm/blob/main/README.md)
🐛 **Issues:** Found a bug? Open an [issue](https://github.com/Gu1llaum-3/sshm/issues)
---
Released with ❤️ by [GoReleaser](https://github.com/goreleaser/goreleaser) Released with ❤️ by [GoReleaser](https://github.com/goreleaser/goreleaser)
# Snapshot builds (for non-tag builds) # Snapshot builds (for non-tag builds)

112
README.md
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 Conversion** - Seamlessly converts between command-line and config formats
- **🔄 Automatic Backups** - Backup configurations automatically before changes - **🔄 Automatic Backups** - Backup configurations automatically before changes
- **✅ Validation** - Prevent configuration errors with built-in validation - **✅ 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 - **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts
- **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows - **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **⚡ Lightweight** - Single binary with no dependencies, zero configuration required - **⚡ 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) - **Port** - SSH port (default: 22)
- **Identity File** - Private key path - **Identity File** - Private key path
- **ProxyJump** - Jump server for connection tunneling - **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`) - **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization - **Tags** - Comma-separated tags for organization
@@ -228,6 +229,15 @@ sshm
# Connect directly to a specific host (with history tracking) # Connect directly to a specific host (with history tracking)
sshm my-server 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 # Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config sshm -c /path/to/custom/ssh_config
@@ -258,6 +268,17 @@ sshm move my-server -c /path/to/custom/ssh_config
# Search for hosts (interactive filter) # Search for hosts (interactive filter)
sshm search sshm search
# Print machine-readable info (JSON) for scripting
sshm info prod-server
sshm info prod-server --pretty
# With a custom SSH config file
sshm -c /path/to/custom/ssh_config info prod-server
# Pipe to jq
sshm info prod-server | jq -r '.result.target.hostname'
sshm info prod-server | jq -r '.result.target.user'
# Show version information (includes update check) # Show version information (includes update check)
sshm --version sshm --version
@@ -265,6 +286,66 @@ sshm --version
sshm --help sshm --help
``` ```
### Host Info (JSON)
`sshm info <hostname>` prints a single JSON object to stdout so you can script against it with `jq`.
```bash
# Extract fields
sshm info prod-server | jq -r '.result.target.hostname'
sshm info prod-server | jq -r '.result.target.port'
# Check not-found (exit code 2)
sshm info does-not-exist | jq -r '.error.code'
```
### 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 ### Direct Host Connection
SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow: SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow:
@@ -285,6 +366,33 @@ sshm web-01
- **Error handling** - Clear messages if host doesn't exist or configuration issues - **Error handling** - Clear messages if host doesn't exist or configuration issues
- **Config file support** - Works with custom config files using `-c` flag - **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 ### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
@@ -504,6 +612,7 @@ Host backend-prod
User app User app
Port 22 Port 22
ProxyJump bastion.company.com ProxyJump bastion.company.com
ProxyCommand ssh -W %h:%p Jumphost
IdentityFile ~/.ssh/production_key IdentityFile ~/.ssh/production_key
Compression yes Compression yes
ServerAliveInterval 300 ServerAliveInterval 300
@@ -520,6 +629,7 @@ SSHM supports all standard SSH configuration options:
- `Port` - SSH port number - `Port` - SSH port number
- `IdentityFile` - Path to private key file - `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) - `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) - `Tags` - Custom tags (SSHM extension)
**Additional SSH Options:** **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)
}
}

199
cmd/info.go Normal file
View File

@@ -0,0 +1,199 @@
package cmd
import (
"encoding/json"
"io"
"os"
"strconv"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/spf13/cobra"
)
type infoResponse struct {
Schema string `json:"schema"`
OK bool `json:"ok"`
Hostname string `json:"hostname"`
Result *infoResult `json:"result"`
Error *infoError `json:"error"`
}
type infoResult struct {
CanonicalName string `json:"canonical_name"`
Target infoTarget `json:"target"`
IdentityFile *string `json:"identity_file"`
ProxyJump *string `json:"proxy_jump"`
ProxyCommand *string `json:"proxy_command"`
Options *string `json:"options"`
Tags []string `json:"tags"`
RemoteCommand *string `json:"remote_command"`
RequestTTY *string `json:"request_tty"`
Source *infoSource `json:"source"`
}
type infoTarget struct {
Host string `json:"host"`
Hostname *string `json:"hostname"`
User *string `json:"user"`
Port *int `json:"port"`
}
type infoSource struct {
File string `json:"file"`
Line int `json:"line"`
}
type infoError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func maybeString(v string) *string {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil
}
return &trimmed
}
func maybePort(v string) (*int, error) {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil, nil
}
port, err := strconv.Atoi(trimmed)
if err != nil {
return nil, err
}
return &port, nil
}
func writeInfoJSON(out io.Writer, pretty bool, resp infoResponse) {
var b []byte
var err error
if pretty {
b, err = json.MarshalIndent(resp, "", " ")
} else {
b, err = json.Marshal(resp)
}
if err != nil {
_, _ = io.WriteString(out, `{"schema":"sshm.info.v1","ok":false,"hostname":"","result":null,"error":{"code":"INTERNAL","message":"failed to marshal JSON","details":null}}\n`)
return
}
_, _ = out.Write(append(b, '\n'))
}
func runInfo(out io.Writer, hostnameArg string, cfgFile string, pretty bool) int {
resp := infoResponse{
Schema: "sshm.info.v1",
OK: false,
Hostname: hostnameArg,
Result: nil,
Error: nil,
}
var host *config.SSHHost
var err error
if cfgFile != "" {
host, err = config.GetSSHHostFromFile(hostnameArg, cfgFile)
} else {
host, err = config.GetSSHHost(hostnameArg)
}
if err != nil {
code := 1
errCode := "CONFIG_ERROR"
msg := err.Error()
if strings.Contains(msg, "not found") {
code = 2
errCode = "NOT_FOUND"
}
resp.Error = &infoError{Code: errCode, Message: msg, Details: nil}
writeInfoJSON(out, pretty, resp)
return code
}
port, portErr := maybePort(host.Port)
if portErr != nil {
resp.Error = &infoError{Code: "CONFIG_ERROR", Message: "invalid port in host configuration", Details: nil}
writeInfoJSON(out, pretty, resp)
return 1
}
res := infoResult{
CanonicalName: host.Name,
Target: infoTarget{
Host: hostnameArg,
Hostname: maybeString(host.Hostname),
User: maybeString(host.User),
Port: port,
},
IdentityFile: maybeString(host.Identity),
ProxyJump: maybeString(host.ProxyJump),
ProxyCommand: maybeString(host.ProxyCommand),
Options: maybeString(host.Options),
Tags: host.Tags,
RemoteCommand: maybeString(host.RemoteCommand),
RequestTTY: maybeString(host.RequestTTY),
Source: &infoSource{
File: host.SourceFile,
Line: host.LineNumber,
},
}
resp.OK = true
resp.Result = &res
writeInfoJSON(out, pretty, resp)
return 0
}
var infoPretty bool
var infoCmd = &cobra.Command{
Use: "info <hostname>",
Short: "Print machine-readable information about a host",
Long: "Print machine-readable information (JSON) about a configured SSH host.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
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 {
exitCode := runInfo(cmd.OutOrStdout(), args[0], configFile, infoPretty)
if exitCode != 0 {
os.Exit(exitCode)
}
return nil
},
}
func init() {
infoCmd.Flags().BoolVar(&infoPretty, "pretty", false, "Pretty-print JSON output")
RootCmd.AddCommand(infoCmd)
}

321
cmd/info_test.go Normal file
View File

@@ -0,0 +1,321 @@
package cmd
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
type infoResponseForTest struct {
Schema string `json:"schema"`
OK bool `json:"ok"`
Hostname string `json:"hostname"`
Result *infoResultForTest `json:"result"`
Error *infoErrorForTest `json:"error"`
}
type infoResultForTest struct {
CanonicalName string `json:"canonical_name"`
Target infoTargetForTest `json:"target"`
IdentityFile *string `json:"identity_file"`
ProxyJump *string `json:"proxy_jump"`
ProxyCommand *string `json:"proxy_command"`
Options *string `json:"options"`
Tags []string `json:"tags"`
RemoteCommand *string `json:"remote_command"`
RequestTTY *string `json:"request_tty"`
Source *infoSourceForTest `json:"source"`
}
type infoTargetForTest struct {
Host string `json:"host"`
Hostname *string `json:"hostname"`
User *string `json:"user"`
Port *int `json:"port"`
}
type infoSourceForTest struct {
File string `json:"file"`
Line int `json:"line"`
}
type infoErrorForTest struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func TestInfoCommandConfig(t *testing.T) {
if infoCmd.Use != "info <hostname>" {
t.Fatalf("infoCmd.Use=%q", infoCmd.Use)
}
err := infoCmd.Args(infoCmd, []string{})
if err == nil {
t.Fatalf("expected args error for no args")
}
err = infoCmd.Args(infoCmd, []string{"one", "two"})
if err == nil {
t.Fatalf("expected args error for too many args")
}
err = infoCmd.Args(infoCmd, []string{"host"})
if err != nil {
t.Fatalf("expected no args error, got %v", err)
}
}
func TestInfoCommandRegistration(t *testing.T) {
found := false
for _, c := range RootCmd.Commands() {
if c.Name() == "info" {
found = true
break
}
}
if !found {
t.Fatalf("info command not registered")
}
}
func TestRunInfoSuccessJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := filepath.Join(tempDir, "config")
cfgContent := `# Tags: prod, web
Host prod-web
HostName 10.0.0.10
User deploy
Port 2222
IdentityFile ~/.ssh/id_prod
ProxyJump bastion
ServerAliveInterval 60
`
if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
buf := new(bytes.Buffer)
exitCode := runInfo(buf, "prod-web", cfg, false)
if exitCode != 0 {
t.Fatalf("exitCode=%d", exitCode)
}
out := buf.String()
if strings.TrimSpace(out) == "" {
t.Fatalf("expected output")
}
var resp infoResponseForTest
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output not JSON: %v\noutput=%q", err, out)
}
if resp.Schema != "sshm.info.v1" {
t.Fatalf("schema=%q", resp.Schema)
}
if !resp.OK {
t.Fatalf("ok=false")
}
if resp.Result == nil {
t.Fatalf("result is nil")
}
if resp.Error != nil {
t.Fatalf("error is non-nil")
}
if resp.Result.CanonicalName != "prod-web" {
t.Fatalf("canonical_name=%q", resp.Result.CanonicalName)
}
if resp.Result.Target.Host != "prod-web" {
t.Fatalf("target.host=%q", resp.Result.Target.Host)
}
if resp.Result.Target.Hostname == nil || *resp.Result.Target.Hostname != "10.0.0.10" {
t.Fatalf("target.hostname=%v", resp.Result.Target.Hostname)
}
if resp.Result.Target.User == nil || *resp.Result.Target.User != "deploy" {
t.Fatalf("target.user=%v", resp.Result.Target.User)
}
if resp.Result.Target.Port == nil || *resp.Result.Target.Port != 2222 {
t.Fatalf("target.port=%v", resp.Result.Target.Port)
}
if resp.Result.Source == nil || resp.Result.Source.File == "" || resp.Result.Source.Line == 0 {
t.Fatalf("source missing: %#v", resp.Result.Source)
}
if resp.Result.IdentityFile == nil || *resp.Result.IdentityFile != "~/.ssh/id_prod" {
t.Fatalf("identity_file=%v", resp.Result.IdentityFile)
}
if resp.Result.ProxyJump == nil || *resp.Result.ProxyJump != "bastion" {
t.Fatalf("proxy_jump=%v", resp.Result.ProxyJump)
}
}
func TestRunInfoNotFoundJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := filepath.Join(tempDir, "config")
cfgContent := `Host known
HostName example.com
`
if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
buf := new(bytes.Buffer)
exitCode := runInfo(buf, "missing", cfg, false)
if exitCode != 2 {
t.Fatalf("exitCode=%d", exitCode)
}
var resp infoResponseForTest
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("output not JSON: %v", err)
}
if resp.OK {
t.Fatalf("ok=true")
}
if resp.Error == nil {
t.Fatalf("error is nil")
}
if resp.Error.Code != "NOT_FOUND" {
t.Fatalf("error.code=%q", resp.Error.Code)
}
}
func TestRunInfoPrettyJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := filepath.Join(tempDir, "config")
cfgContent := `Host known
HostName 127.0.0.1
`
if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
buf := new(bytes.Buffer)
exitCode := runInfo(buf, "known", cfg, true)
if exitCode != 0 {
t.Fatalf("exitCode=%d", exitCode)
}
out := buf.String()
if !strings.Contains(out, "\n") {
t.Fatalf("expected pretty output")
}
var resp infoResponseForTest
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("output not JSON: %v", err)
}
if !resp.OK {
t.Fatalf("ok=false")
}
}
func TestInfoValidArgsFunction(t *testing.T) {
if infoCmd.ValidArgsFunction == nil {
t.Fatalf("expected ValidArgsFunction to be set on infoCmd")
}
}
func TestInfoValidArgsFunctionWithSSHConfig(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
`
if err := os.WriteFile(testConfigFile, []byte(sshConfig), 0600); 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 := infoCmd.ValidArgsFunction(infoCmd, tt.args, tt.toComplete)
if len(completions) != tt.wantCount {
t.Fatalf("Expected %d completions, got %d: %v", tt.wantCount, len(completions), completions)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("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.Fatalf("Expected completion %q not found in %v", wantHost, completions)
}
}
})
}
}

View File

@@ -24,33 +24,79 @@ var AppVersion = "dev"
// configFile holds the path to the SSH config file // configFile holds the path to the SSH config file
var configFile string 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 // RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "sshm [host]", Use: "sshm [host] [command...]",
Short: "SSH Manager - A modern SSH connection manager", Short: "SSH Manager - A modern SSH connection manager",
Long: `SSHM is a modern SSH manager for your terminal. Long: `SSHM is a modern SSH manager for your terminal.
Main usage: Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically. 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>' 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. 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, Version: AppVersion,
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves 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 { RunE: func(cmd *cobra.Command, args []string) error {
// If no arguments provided, run interactive mode
if len(args) == 0 { if len(args) == 0 {
runInteractiveMode() runInteractiveMode()
return nil return nil
} }
// If a host name is provided, connect directly
hostName := args[0] hostName := args[0]
connectToHost(hostName) var remoteCommand []string
if len(args) > 1 {
remoteCommand = args[1:]
}
connectToHost(hostName, remoteCommand)
return nil return nil
}, },
} }
@@ -97,13 +143,12 @@ func runInteractiveMode() {
} }
// Run the interactive TUI // 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) log.Fatalf("Error running interactive mode: %v", err)
} }
} }
func connectToHost(hostName string) { func connectToHost(hostName string, remoteCommand []string) {
// Quick check if host exists without full parsing (optimized for connection)
var hostFound bool var hostFound bool
var err error var err error
@@ -123,39 +168,42 @@ func connectToHost(hostName string) {
os.Exit(1) os.Exit(1)
} }
// Record the connection in history
historyManager, err := history.NewHistoryManager() historyManager, err := history.NewHistoryManager()
if err != nil { if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err) fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else { } else {
err = historyManager.RecordConnection(hostName) err = historyManager.RecordConnection(hostName)
if err != nil { if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err) fmt.Printf("Warning: Could not record connection history: %v\n", err)
} }
} }
// Build and execute the SSH command var args []string
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
if configFile != "" { if configFile != "" {
sshCmd = exec.Command("ssh", "-F", configFile, hostName) args = append(args, "-F", configFile)
} else {
sshCmd = exec.Command("ssh", hostName)
} }
// Set up the command to use the same stdin, stdout, and stderr as the parent process if forceTTY {
args = append(args, "-t")
}
args = append(args, hostName)
if len(remoteCommand) > 0 {
args = append(args, remoteCommand...)
} else {
fmt.Printf("Connecting to %s...\n", hostName)
}
sshCmd := exec.Command("ssh", args...)
sshCmd.Stdin = os.Stdin sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run() err = sshCmd.Run()
if err != nil { if err != nil {
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok { if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus()) os.Exit(status.ExitStatus())
} }
@@ -191,17 +239,13 @@ func getVersionWithUpdateCheck() string {
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
func Execute() { func Execute() {
// Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil { 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() errStr := err.Error()
if strings.Contains(errStr, "unknown command") { if strings.Contains(errStr, "unknown command") {
// Extract the command name from the error
parts := strings.Split(errStr, "\"") parts := strings.Split(errStr, "\"")
if len(parts) >= 2 { if len(parts) >= 2 {
potentialHost := parts[1] potentialHost := parts[1]
// Try to connect to this as a host connectToHost(potentialHost, nil)
connectToHost(potentialHost)
return return
} }
} }
@@ -211,8 +255,9 @@ func Execute() {
} }
func init() { func init() {
// Add the config file flag
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") 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 // Set custom version template with update check
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck()) RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())

View File

@@ -7,9 +7,8 @@ import (
) )
func TestRootCommand(t *testing.T) { func TestRootCommand(t *testing.T) {
// Test that the root command is properly configured if RootCmd.Use != "sshm [host] [command...]" {
if RootCmd.Use != "sshm [host]" { t.Errorf("Expected Use 'sshm [host] [command...]', got '%s'", RootCmd.Use)
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
} }
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" { if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
@@ -22,10 +21,8 @@ func TestRootCommand(t *testing.T) {
} }
func TestRootCommandFlags(t *testing.T) { func TestRootCommandFlags(t *testing.T) {
// Test that persistent flags are properly configured
flags := RootCmd.PersistentFlags() flags := RootCmd.PersistentFlags()
// Check config flag
configFlag := flags.Lookup("config") configFlag := flags.Lookup("config")
if configFlag == nil { if configFlag == nil {
t.Error("Expected --config flag to be defined") t.Error("Expected --config flag to be defined")
@@ -34,12 +31,21 @@ func TestRootCommandFlags(t *testing.T) {
if configFlag.Shorthand != "c" { if configFlag.Shorthand != "c" {
t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand) 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) { func TestRootCommandSubcommands(t *testing.T) {
// Test that all expected subcommands are registered // Test that all expected subcommands are registered
// Note: completion and help are automatically added by Cobra and may not always appear in Commands() // Note: completion and help are automatically added by Cobra and may not always appear in Commands()
expectedCommands := []string{"add", "edit", "search"} expectedCommands := []string{"add", "edit", "search", "info"}
commands := RootCmd.Commands() commands := RootCmd.Commands()
commandNames := make(map[string]bool) commandNames := make(map[string]bool)
@@ -103,13 +109,17 @@ func TestExecuteFunction(t *testing.T) {
} }
func TestConnectToHostFunction(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") t.Log("connectToHost function exists and is accessible")
}
// The function will handle errors internally (like host not found) func TestRemoteCommandUsage(t *testing.T) {
// We don't want to actually test the SSH connection in unit tests 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) { 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(" \"port\": \"%s\",\n", escapeJSON(host.Port))
fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity)) fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity))
fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump)) 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(" \"options\": \"%s\",\n", escapeJSON(host.Options))
fmt.Printf(" \"tags\": [") fmt.Printf(" \"tags\": [")
for j, tag := range host.Tags { for j, tag := range host.Tags {

View File

@@ -11,17 +11,33 @@ import (
"sync" "sync"
) )
func getHomeDir() (string, error) {
home := os.Getenv("HOME")
if home != "" {
return home, nil
}
home = os.Getenv("USERPROFILE")
if home != "" {
return home, nil
}
return os.UserHomeDir()
}
// SSHHost represents an SSH host configuration // SSHHost represents an SSH host configuration
type SSHHost struct { type SSHHost struct {
Name string Name string
Hostname string Hostname string
User string User string
Port string Port string
Identity string Identity string
ProxyJump string ProxyJump string
Options string ProxyCommand string
Tags []string Options string
SourceFile string // Path to the config file where this host is defined 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 // Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field aliasNames []string `json:"-"` // Do not serialize this field
@@ -29,7 +45,7 @@ type SSHHost struct {
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform // GetDefaultSSHConfigPath returns the default SSH config path for the current platform
func GetDefaultSSHConfigPath() (string, error) { func GetDefaultSSHConfigPath() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := getHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -45,7 +61,7 @@ func GetDefaultSSHConfigPath() (string, error) {
// GetSSHMConfigDir returns the SSHM config directory // GetSSHMConfigDir returns the SSHM config directory
func GetSSHMConfigDir() (string, error) { func GetSSHMConfigDir() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := getHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -84,7 +100,7 @@ func GetSSHMBackupDir() (string, error) {
// GetSSHDirectory returns the .ssh directory path // GetSSHDirectory returns the .ssh directory path
func GetSSHDirectory() (string, error) { func GetSSHDirectory() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := getHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -209,8 +225,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
var currentHost *SSHHost var currentHost *SSHHost
var pendingTags []string var pendingTags []string
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() { for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
// Ignore empty lines // Ignore empty lines
@@ -277,8 +295,12 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
hostNames := strings.Fields(value) hostNames := strings.Fields(value)
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts // Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
// Also remove surrounding quotes from host names
var validHostNames []string var validHostNames []string
for _, hostName := range hostNames { for _, hostName := range hostNames {
// Remove surrounding double quotes if present
hostName = strings.Trim(hostName, `"`)
if !strings.ContainsAny(hostName, "*?") { if !strings.ContainsAny(hostName, "*?") {
validHostNames = append(validHostNames, hostName) validHostNames = append(validHostNames, hostName)
} }
@@ -297,6 +319,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
Port: "22", // Default port Port: "22", // Default port
Tags: pendingTags, // Assign pending tags to this host Tags: pendingTags, // Assign pending tags to this host
SourceFile: absPath, // Track which file this host comes from 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 // Store additional host names for later processing
@@ -326,6 +349,18 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil { if currentHost != nil {
currentHost.ProxyJump = value currentHost.ProxyJump = value
} }
case "proxycommand":
if currentHost != nil {
currentHost.ProxyCommand = value
}
case "remotecommand":
if currentHost != nil {
currentHost.RemoteCommand = value
}
case "requesttty":
if currentHost != nil {
currentHost.RequestTTY = value
}
default: default:
// Handle other SSH options // Handle other SSH options
if currentHost != nil && strings.TrimSpace(line) != "" { if currentHost != nil && strings.TrimSpace(line) != "" {
@@ -363,7 +398,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) { func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
// Expand tilde to home directory // Expand tilde to home directory
if strings.HasPrefix(pattern, "~") { if strings.HasPrefix(pattern, "~") {
homeDir, err := os.UserHomeDir() homeDir, err := getHomeDir()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err) return nil, fmt.Errorf("failed to get home directory: %w", err)
} }
@@ -603,6 +638,27 @@ 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 {
return err
}
}
if host.RequestTTY != "" {
_, err = file.WriteString(fmt.Sprintf(" RequestTTY %s\n", host.RequestTTY))
if err != nil {
return err
}
}
// Write SSH options // Write SSH options
if host.Options != "" { if host.Options != "" {
// Split options by newlines and write each one // Split options by newlines and write each one
@@ -622,13 +678,34 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
} }
// ParseSSHOptionsFromCommand converts SSH command line options to config format // 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" // Output: "Compression yes\nServerAliveInterval 60"
func ParseSSHOptionsFromCommand(options string) string { func ParseSSHOptionsFromCommand(options string) string {
if options == "" { if options == "" {
return "" 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 var result []string
parts := strings.Split(options, "-o") parts := strings.Split(options, "-o")
@@ -654,6 +731,12 @@ func FormatSSHOptionsForCommand(options string) string {
return "" 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 var result []string
lines := strings.Split(options, "\n") lines := strings.Split(options, "\n")
@@ -829,6 +912,9 @@ func quickHostSearchInFile(hostName string, configPath string, processedFiles ma
// Check if our target host is in this Host declaration // Check if our target host is in this Host declaration
for _, candidateHostName := range hostNames { for _, candidateHostName := range hostNames {
// Remove surrounding double quotes if present
candidateHostName = strings.Trim(candidateHostName, `"`)
// Skip hosts with wildcards (*, ?) as they are typically patterns // Skip hosts with wildcards (*, ?) as they are typically patterns
if !strings.ContainsAny(candidateHostName, "*?") && candidateHostName == hostName { if !strings.ContainsAny(candidateHostName, "*?") && candidateHostName == hostName {
return true, nil // Found the host! return true, nil // Found the host!
@@ -844,7 +930,7 @@ func quickHostSearchInFile(hostName string, configPath string, processedFiles ma
func quickSearchInclude(hostName, pattern, baseConfigPath string, processedFiles map[string]bool) (bool, error) { func quickSearchInclude(hostName, pattern, baseConfigPath string, processedFiles map[string]bool) (bool, error) {
// Expand tilde to home directory // Expand tilde to home directory
if strings.HasPrefix(pattern, "~") { if strings.HasPrefix(pattern, "~") {
homeDir, err := os.UserHomeDir() homeDir, err := getHomeDir()
if err != nil { if err != nil {
return false, fmt.Errorf("failed to get home directory: %w", err) return false, fmt.Errorf("failed to get home directory: %w", err)
} }
@@ -1020,6 +1106,15 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1068,6 +1163,15 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1152,6 +1256,15 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1200,6 +1313,15 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1235,11 +1357,21 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
// DeleteSSHHost removes an SSH host configuration from the config file // DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error { 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 // DeleteSSHHostFromFile deletes an SSH host from a specific config file
func DeleteSSHHostFromFile(hostName, configPath string) error { 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() configMutex.Lock()
defer configMutex.Unlock() defer configMutex.Unlock()
@@ -1266,11 +1398,13 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
hostFound := false hostFound := false
for i < len(lines) { for i < len(lines) {
currentLineNumber := i + 1 // Convert 0-indexed to 1-indexed
line := strings.TrimSpace(lines[i]) line := strings.TrimSpace(lines[i])
// Check for tags comment followed by Host // Check for tags comment followed by Host
if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1]) 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 // Check if this is a Host line that contains our target host
if strings.HasPrefix(nextLine, "Host ") { if strings.HasPrefix(nextLine, "Host ") {
@@ -1286,7 +1420,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 hostFound = true
if isMultiHost && len(hostNames) > 1 { if isMultiHost && len(hostNames) > 1 {
@@ -1324,7 +1461,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else { } else {
// Single host or last host in multi-host block, delete entire block // Single host or last host in multi-host block, delete entire block
// Skip tags comment and Host line // Skip tags comment and Host line
@@ -1340,7 +1482,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} }
} }
} }
@@ -1360,7 +1507,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 hostFound = true
if isMultiHost && len(hostNames) > 1 { if isMultiHost && len(hostNames) > 1 {
@@ -1395,7 +1545,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} else { } else {
// Single host, delete entire block // Single host, delete entire block
// Skip Host line // Skip Host line
@@ -1411,7 +1566,12 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
i++ i++
} }
continue // Copy remaining lines and break to prevent deleting other duplicates
for i < len(lines) {
newLines = append(newLines, lines[i])
i++
}
break
} }
} }
} }
@@ -1494,15 +1654,15 @@ func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
} }
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files // 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 // Find the host to determine which file it's in
existingHost, err := FindHostInAllConfigs(hostName) existingHost, err := FindHostInAllConfigs(hostName)
if err != nil { if err != nil {
return err return err
} }
// Delete the host from its source file // Delete the host from its source file using line number if provided
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile) return DeleteSSHHostFromFileWithLine(hostName, existingHost.SourceFile, targetLineNumber)
} }
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file // AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
@@ -1694,6 +1854,15 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {
@@ -1774,6 +1943,15 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+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)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {

View File

@@ -456,15 +456,21 @@ func TestBackupConfigToSSHMDirectory(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
// Override the home directory for this test
originalHome := os.Getenv("HOME") originalHome := os.Getenv("HOME")
if originalHome == "" { if originalHome == "" {
originalHome = os.Getenv("USERPROFILE") // Windows originalHome = os.Getenv("USERPROFILE")
} }
originalXDG := os.Getenv("XDG_CONFIG_HOME")
originalAppData := os.Getenv("APPDATA")
// Set test home directory
os.Setenv("HOME", tempDir) os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome) os.Setenv("XDG_CONFIG_HOME", tempDir)
os.Setenv("APPDATA", tempDir)
defer func() {
os.Setenv("HOME", originalHome)
os.Setenv("XDG_CONFIG_HOME", originalXDG)
os.Setenv("APPDATA", originalAppData)
}()
// Create a test SSH config file // Create a test SSH config file
sshDir := filepath.Join(tempDir, ".ssh") sshDir := filepath.Join(tempDir, ".ssh")
@@ -1694,3 +1700,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

@@ -2,12 +2,15 @@ package connectivity
import ( import (
"context" "context"
"fmt"
"net" "net"
"github.com/Gu1llaum-3/sshm/internal/config" "os/exec"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/Gu1llaum-3/sshm/internal/config"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -45,16 +48,18 @@ type HostPingResult struct {
// PingManager manages SSH connectivity checks for multiple hosts // PingManager manages SSH connectivity checks for multiple hosts
type PingManager struct { type PingManager struct {
results map[string]*HostPingResult results map[string]*HostPingResult
mutex sync.RWMutex mutex sync.RWMutex
timeout time.Duration timeout time.Duration
configFile string
} }
// NewPingManager creates a new ping manager with the specified timeout // NewPingManager creates a new ping manager with the specified timeout
func NewPingManager(timeout time.Duration) *PingManager { func NewPingManager(timeout time.Duration, configFile string) *PingManager {
return &PingManager{ return &PingManager{
results: make(map[string]*HostPingResult), results: make(map[string]*HostPingResult),
timeout: timeout, timeout: timeout,
configFile: configFile,
} }
} }
@@ -98,6 +103,14 @@ func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostP
// Mark as connecting // Mark as connecting
pm.updateStatus(host.Name, StatusConnecting, nil, 0) pm.updateStatus(host.Name, StatusConnecting, nil, 0)
// If the host uses a ProxyJump or ProxyCommand, we need to use the external SSH command
// because implementing jump host support with pure Go ssh library requires
// handling authentication for the jump host, which is complex and requires
// access to the user's SSH agent or keys.
if host.ProxyJump != "" || host.ProxyCommand != "" {
return pm.pingWithExternalCommand(ctx, host, start)
}
// Determine the actual hostname and port // Determine the actual hostname and port
hostname := host.Hostname hostname := host.Hostname
if hostname == "" { if hostname == "" {
@@ -159,6 +172,53 @@ func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostP
} }
} }
// pingWithExternalCommand pings a host using the external SSH command
func (pm *PingManager) pingWithExternalCommand(ctx context.Context, host config.SSHHost, start time.Time) *HostPingResult {
// Construct the SSH command
// ssh -q -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 host exit
args := []string{"-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no"}
// Set timeout matching the manager's timeout
// Convert duration to seconds (rounding up to ensure we don't timeout too early in the command)
timeoutSec := int(pm.timeout.Seconds())
if timeoutSec < 1 {
timeoutSec = 1
}
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", timeoutSec))
// If we have a specific config file, use it
if pm.configFile != "" {
args = append(args, "-F", pm.configFile)
}
// Add the host name and the command to run (exit)
args = append(args, host.Name, "exit")
// Create command with context for timeout cancellation
// Note: We used pm.timeout for the ssh command option, but we also respect the context deadline
cmd := exec.CommandContext(ctx, "ssh", args...)
// Run the command
err := cmd.Run()
duration := time.Since(start)
var status PingStatus
if err != nil {
// SSH returns non-zero exit code on connection failure
status = StatusOffline
} else {
status = StatusOnline
}
pm.updateStatus(host.Name, status, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: status,
Error: err,
Duration: duration,
}
}
// PingAllHosts pings all hosts concurrently and returns a channel of results // PingAllHosts pings all hosts concurrently and returns a channel of results
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult { func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
resultChan := make(chan *HostPingResult, len(hosts)) resultChan := make(chan *HostPingResult, len(hosts))

View File

@@ -9,7 +9,7 @@ import (
) )
func TestNewPingManager(t *testing.T) { func TestNewPingManager(t *testing.T) {
pm := NewPingManager(5 * time.Second) pm := NewPingManager(5*time.Second, "")
if pm == nil { if pm == nil {
t.Error("NewPingManager() returned nil") t.Error("NewPingManager() returned nil")
} }
@@ -19,7 +19,7 @@ func TestNewPingManager(t *testing.T) {
} }
func TestPingManager_PingHost(t *testing.T) { func TestPingManager_PingHost(t *testing.T) {
pm := NewPingManager(1 * time.Second) pm := NewPingManager(1*time.Second, "")
ctx := context.Background() ctx := context.Background()
// Test ping method exists and doesn't panic // Test ping method exists and doesn't panic
@@ -38,7 +38,7 @@ func TestPingManager_PingHost(t *testing.T) {
} }
func TestPingManager_GetStatus(t *testing.T) { func TestPingManager_GetStatus(t *testing.T) {
pm := NewPingManager(1 * time.Second) pm := NewPingManager(1*time.Second, "")
// Test unknown host // Test unknown host
status := pm.GetStatus("unknown.host") status := pm.GetStatus("unknown.host")
@@ -57,7 +57,7 @@ func TestPingManager_GetStatus(t *testing.T) {
} }
func TestPingManager_PingMultipleHosts(t *testing.T) { func TestPingManager_PingMultipleHosts(t *testing.T) {
pm := NewPingManager(1 * time.Second) pm := NewPingManager(1*time.Second, "")
hosts := []config.SSHHost{ hosts := []config.SSHHost{
{Name: "localhost", Hostname: "127.0.0.1", Port: "22"}, {Name: "localhost", Hostname: "127.0.0.1", Port: "22"},
{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"}, {Name: "invalid", Hostname: "invalid.host.12345", Port: "22"},
@@ -81,7 +81,7 @@ func TestPingManager_PingMultipleHosts(t *testing.T) {
} }
func TestPingManager_GetResult(t *testing.T) { func TestPingManager_GetResult(t *testing.T) {
pm := NewPingManager(1 * time.Second) pm := NewPingManager(1*time.Second, "")
ctx := context.Background() ctx := context.Background()
// Test getting result for unknown host // Test getting result for unknown host
@@ -126,7 +126,7 @@ func TestPingStatus_String(t *testing.T) {
func TestPingHost_Basic(t *testing.T) { func TestPingHost_Basic(t *testing.T) {
// Test that the ping functionality exists // Test that the ping functionality exists
pm := NewPingManager(1 * time.Second) pm := NewPingManager(1*time.Second, "")
ctx := context.Background() ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"} host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}

View File

@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"os" "os"
"os/user" "os/user"
"path/filepath" "path/filepath"
@@ -16,6 +17,7 @@ import (
type addFormModel struct { type addFormModel struct {
inputs []textinput.Model inputs []textinput.Model
focused int focused int
currentTab int // 0 = General, 1 = Advanced
err string err string
styles Styles styles Styles
success bool success bool
@@ -47,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
inputs := make([]textinput.Model, 8) inputs := make([]textinput.Model, 11)
// Name input // Name input
inputs[nameInput] = textinput.New() inputs[nameInput] = textinput.New()
@@ -89,6 +91,12 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[proxyJumpInput].CharLimit = 200 inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50 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 // SSH Options input
inputs[optionsInput] = textinput.New() inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
@@ -101,9 +109,22 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[tagsInput].CharLimit = 200 inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50 inputs[tagsInput].Width = 50
// Remote Command input
inputs[remoteCommandInput] = textinput.New()
inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash"
inputs[remoteCommandInput].CharLimit = 300
inputs[remoteCommandInput].Width = 70
// RequestTTY input
inputs[requestTTYInput] = textinput.New()
inputs[requestTTYInput].Placeholder = "yes, no, force, auto"
inputs[requestTTYInput].CharLimit = 10
inputs[requestTTYInput].Width = 30
return &addFormModel{ return &addFormModel{
inputs: inputs, inputs: inputs,
focused: nameInput, focused: nameInput,
currentTab: tabGeneral, // Start on General tab
styles: styles, styles: styles,
width: width, width: width,
height: height, height: height,
@@ -111,6 +132,11 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
const (
tabGeneral = iota
tabAdvanced
)
const ( const (
nameInput = iota nameInput = iota
hostnameInput hostnameInput
@@ -118,8 +144,12 @@ const (
portInput portInput
identityInput identityInput
proxyJumpInput proxyJumpInput
proxyCommandInput
optionsInput optionsInput
tagsInput tagsInput
// Advanced tab inputs
remoteCommandInput
requestTTYInput
) )
// Messages for communication with parent model // Messages for communication with parent model
@@ -153,36 +183,20 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
// Allow submission from any field with Ctrl+S (Save) // Allow submission from any field with Ctrl+S (Save)
return m, m.submitForm() return m, m.submitForm()
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down": case "tab", "shift+tab", "enter", "up", "down":
s := msg.String() return m, m.handleNavigation(msg.String())
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
} }
case addFormSubmitMsg: case addFormSubmitMsg:
@@ -206,32 +220,122 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// getFirstInputForTab returns the first input index for a given tab
func (m *addFormModel) getFirstInputForTab(tab int) int {
switch tab {
case tabGeneral:
return nameInput
case tabAdvanced:
return optionsInput
default:
return nameInput
}
}
// getInputsForCurrentTab returns the input indices for the current tab
func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab {
case tabGeneral:
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, proxyCommandInput, tagsInput}
}
}
// updateFocus updates focus for inputs
func (m *addFormModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
return tea.Batch(cmds...)
}
// handleNavigation handles tab/arrow navigation within the current tab
func (m *addFormModel) handleNavigation(key string) tea.Cmd {
currentTabInputs := m.getInputsForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, input := range currentTabInputs {
if input == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == tabAdvanced && currentPos == len(currentTabInputs)-1 {
return m.submitForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Handle transitions between tabs
if currentPos >= len(currentTabInputs) {
// 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 {
// 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]
return m.updateFocus()
}
func (m *addFormModel) View() string { func (m *addFormModel) View() string {
if m.success { if m.success {
return "" return ""
} }
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
}
var b strings.Builder var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration")) b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n") b.WriteString("\n\n")
fields := []string{ // Render tabs
"Host Name *", b.WriteString(m.renderTabs())
"Hostname/IP *", b.WriteString("\n\n")
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields { // Render current tab content
b.WriteString(m.styles.FormField.Render(field)) switch m.currentTab {
b.WriteString("\n") case tabGeneral:
b.WriteString(m.inputs[i].View()) b.WriteString(m.renderGeneralTab())
b.WriteString("\n\n") case tabAdvanced:
b.WriteString(m.renderAdvancedTab())
} }
if m.err != "" { if m.err != "" {
@@ -239,13 +343,134 @@ func (m *addFormModel) View() string {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel")) // Help text
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Ctrl+J/K: switch tabs"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("* Required fields")) b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String() return b.String()
} }
// getMinimumHeight calculates the minimum height needed to display the form
func (m *addFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == tabGeneral {
fieldsCount = 7 // 7 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: label (1) + input (1) + spacing (2) = 4 lines per field, but let's be more conservative
fieldsLines := fieldsCount * 3 // Reduced from 4 to 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *addFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *addFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
// renderTabs renders the tab headers
func (m *addFormModel) renderTabs() string {
var generalTab, advancedTab string
if m.currentTab == tabGeneral {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderGeneralTab renders the general tab content
func (m *addFormModel) renderGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{nameInput, "Host Name *"},
{hostnameInput, "Hostname/IP *"},
{userInput, "User"},
{portInput, "Port"},
{identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"},
{proxyCommandInput, "ProxyCommand"},
{tagsInput, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderAdvancedTab renders the advanced tab content
func (m *addFormModel) renderAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{optionsInput, "SSH Options"},
{remoteCommandInput, "Remote Command"},
{requestTTYInput, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for add form // Standalone wrapper for add form
type standaloneAddForm struct { type standaloneAddForm struct {
*addFormModel *addFormModel
@@ -290,7 +515,10 @@ func (m *addFormModel) submitForm() tea.Cmd {
port := strings.TrimSpace(m.inputs[portInput].Value()) port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
proxyCommand := strings.TrimSpace(m.inputs[proxyCommandInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
// Set defaults // Set defaults
if user == "" { if user == "" {
@@ -319,14 +547,17 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Create host configuration // Create host configuration
host := config.SSHHost{ host := config.SSHHost{
Name: name, Name: name,
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options), ProxyCommand: proxyCommand,
Tags: tags, Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
// Add to config // Add to config

View File

@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
const ( const (
@@ -29,8 +28,8 @@ type editFormModel struct {
inputs []textinput.Model inputs []textinput.Model
focusArea int // 0=hosts, 1=properties focusArea int // 0=hosts, 1=properties
focused int focused int
currentTab int // 0=General, 1=Advanced (only applies when focusArea == focusAreaProperties)
err string err string
success bool
styles Styles styles Styles
originalName string originalName string
originalHosts []string // Store original host names for multi-host detection originalHosts []string // Store original host names for multi-host detection
@@ -92,7 +91,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
} }
} }
inputs := make([]textinput.Model, 7) // Reduced from 8 since we removed nameInput inputs := make([]textinput.Model, 10)
// Hostname input // Hostname input
inputs[0] = textinput.New() inputs[0] = textinput.New()
@@ -129,29 +128,51 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[4].Width = 30 inputs[4].Width = 30
inputs[4].SetValue(host.ProxyJump) inputs[4].SetValue(host.ProxyJump)
// Options input // ProxyCommand input
inputs[5] = textinput.New() inputs[5] = textinput.New()
inputs[5].Placeholder = "-o StrictHostKeyChecking=no" inputs[5].Placeholder = "ssh -W %h:%p Jumphost"
inputs[5].CharLimit = 200 inputs[5].CharLimit = 200
inputs[5].Width = 50 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 != "" { if host.Options != "" {
inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options)) inputs[6].SetValue(config.FormatSSHOptionsForCommand(host.Options))
} }
// Tags input // Tags input
inputs[6] = textinput.New() inputs[7] = textinput.New()
inputs[6].Placeholder = "production, web, database" inputs[7].Placeholder = "production, web, database"
inputs[6].CharLimit = 200 inputs[7].CharLimit = 200
inputs[6].Width = 50 inputs[7].Width = 50
if len(host.Tags) > 0 { if len(host.Tags) > 0 {
inputs[6].SetValue(strings.Join(host.Tags, ", ")) inputs[7].SetValue(strings.Join(host.Tags, ", "))
} }
// Remote Command input
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[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{ return &editFormModel{
hostInputs: hostInputs, hostInputs: hostInputs,
inputs: inputs, inputs: inputs,
focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing
focused: 0, focused: 0,
currentTab: 0, // Start on General tab
originalName: hostName, originalName: hostName,
originalHosts: hostNames, originalHosts: hostNames,
host: host, host: host,
@@ -235,6 +256,157 @@ func (m *editFormModel) updateFocus() tea.Cmd {
return textinput.Blink return textinput.Blink
} }
// getPropertiesForCurrentTab returns the property input indices for the current tab
func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab {
case 0: // General
return []int{0, 1, 2, 3, 4, 5, 7} // hostname, user, port, identity, proxyjump, proxycommand, tags
case 1: // Advanced
return []int{6, 8, 9} // options, remotecommand, requesttty
default:
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, 5, 7} // General tab
if tab == 1 {
properties = []int{6, 8, 9} // Advanced tab
}
if len(properties) > 0 {
return properties[0]
}
return 0
}
// handleEditNavigation handles navigation in the edit form with tab support
func (m *editFormModel) handleEditNavigation(key string) tea.Cmd {
if m.focusArea == focusAreaHosts {
// Navigate in hosts area
if key == "up" || key == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused >= len(m.hostInputs) {
// Move to properties area, keep current tab
m.focusArea = focusAreaProperties
// Keep the current tab instead of forcing it to 0
m.focused = m.getFirstPropertyForTab(m.currentTab)
} else if m.focused < 0 {
m.focused = len(m.hostInputs) - 1
}
} else {
// Navigate in properties area within current tab
currentTabProperties := m.getPropertiesForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, prop := range currentTabProperties {
if prop == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == 1 && currentPos == len(currentTabProperties)-1 {
return m.submitEditForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Handle transitions between areas and tabs
if currentPos >= len(currentTabProperties) {
// Move to next area/tab
if m.currentTab == 0 {
// Move to advanced tab
m.currentTab = 1
m.focused = m.getFirstPropertyForTab(1)
} else {
// Move back to hosts area
m.focusArea = focusAreaHosts
m.focused = 0
}
} else if currentPos < 0 {
// Move to previous area/tab
if m.currentTab == 1 {
// Move to general tab
m.currentTab = 0
properties := m.getPropertiesForCurrentTab()
m.focused = properties[len(properties)-1]
} else {
// Move to hosts area
m.focusArea = focusAreaHosts
m.focused = len(m.hostInputs) - 1
}
} else {
m.focused = currentTabProperties[currentPos]
}
}
return m.updateFocus()
}
// getMinimumHeight calculates the minimum height needed to display the edit form
func (m *editFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Config file info: 1 line + 2 newlines = 3
configLines := 3
// Host Names section: title (1) + spacing (2) = 3
hostSectionLines := 3
// Host inputs: number of hosts * 3 lines each (reduced from 4)
hostLines := len(m.hostInputs) * 3
// Properties section: title (1) + spacing (2) = 3
propertiesSectionLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == 0 {
fieldsCount = 6 // 6 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: reduced from 4 to 3 lines per field
fieldsLines := fieldsCount * 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + configLines + hostSectionLines + hostLines + propertiesSectionLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *editFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *editFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -247,51 +419,33 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
m.err = "" m.err = ""
m.success = false
return m, func() tea.Msg { return editFormCancelMsg{} } return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s": case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save) // Allow submission from any field with Ctrl+S (Save)
return m, m.submitEditForm() return m, m.submitEditForm()
case "tab", "shift+tab", "enter", "up", "down": case "ctrl+j":
s := msg.String() // Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
// Handle form submission // If we're in hosts area, stay there. If in properties, go to the first field of the new tab
totalFields := len(m.hostInputs) + len(m.inputs)
currentGlobalIndex := m.focused
if m.focusArea == focusAreaProperties { if m.focusArea == focusAreaProperties {
currentGlobalIndex = len(m.hostInputs) + m.focused m.focused = m.getFirstPropertyForTab(m.currentTab)
} }
if s == "enter" && currentGlobalIndex == totalFields-1 {
return m, m.submitEditForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
currentGlobalIndex--
} else {
currentGlobalIndex++
}
if currentGlobalIndex >= totalFields {
currentGlobalIndex = 0
} else if currentGlobalIndex < 0 {
currentGlobalIndex = totalFields - 1
}
// Update focus area and focused index based on global index
if currentGlobalIndex < len(m.hostInputs) {
m.focusArea = focusAreaHosts
m.focused = currentGlobalIndex
} else {
m.focusArea = focusAreaProperties
m.focused = currentGlobalIndex - len(m.hostInputs)
}
return m, m.updateFocus() return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
return m, m.handleEditNavigation(msg.String())
case "ctrl+a": case "ctrl+a":
// Add a new host input // Add a new host input
return m, m.addHostInput() return m, m.addHostInput()
@@ -306,10 +460,10 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editFormSubmitMsg: case editFormSubmitMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.success = false
} else { } else {
m.success = true // Success: let the wrapper handle this
m.err = "" // In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
} }
return m, nil return m, nil
} }
@@ -332,15 +486,13 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *editFormModel) View() string { func (m *editFormModel) View() string {
var b strings.Builder // Check if terminal height is sufficient
if !m.isHeightSufficient() {
if m.success { return m.renderHeightWarning()
b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!"))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back"))
return b.String()
} }
var b strings.Builder
if m.err != "" { if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err)) b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -377,25 +529,16 @@ func (m *editFormModel) View() string {
b.WriteString(m.styles.FormTitle.Render("Common Properties")) b.WriteString(m.styles.FormTitle.Render("Common Properties"))
b.WriteString("\n\n") b.WriteString("\n\n")
fields := []string{ // Render tabs for properties
"Hostname/IP *", b.WriteString(m.renderEditTabs())
"User", b.WriteString("\n\n")
"Port",
"Identity File",
"Proxy Jump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields { // Render current tab content
fieldStyle := m.styles.FormField switch m.currentTab {
if m.focusArea == focusAreaProperties && m.focused == i { case 0: // General
fieldStyle = m.styles.FocusedLabel b.WriteString(m.renderEditGeneralTab())
} case 1: // Advanced
b.WriteString(fieldStyle.Render(field)) b.WriteString(m.renderEditAdvancedTab())
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
} }
if m.err != "" { if m.err != "" {
@@ -405,10 +548,10 @@ func (m *editFormModel) View() string {
// Show different help based on number of hosts // Show different help based on number of hosts
if len(m.hostInputs) > 1 { if len(m.hostInputs) > 1 {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host • Ctrl+D: delete host")) b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString("\n") b.WriteString("\n")
} else { } else {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host")) b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host"))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields")) b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields"))
@@ -416,6 +559,103 @@ func (m *editFormModel) View() string {
return b.String() return b.String()
} }
// renderEditTabs renders the tab headers for properties
func (m *editFormModel) renderEditTabs() string {
var generalTab, advancedTab string
if m.currentTab == 0 {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderEditGeneralTab renders the general tab content for properties
func (m *editFormModel) renderEditGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{0, "Hostname/IP *"},
{1, "User"},
{2, "Port"},
{3, "Identity File"},
{4, "Proxy Jump"},
{5, "Proxy Command"},
{7, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderEditAdvancedTab renders the advanced tab content for properties
func (m *editFormModel) renderEditAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{6, "SSH Options"},
{8, "Remote Command"},
{9, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
return m, nil
} else {
// Success: quit the program
return m, tea.Quit
}
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm.(*editFormModel)
return m, cmd
}
// RunEditForm runs the edit form as a standalone program // RunEditForm runs the edit form as a standalone program
func RunEditForm(hostName string, configFile string) error { func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) // Default width styles := NewStyles(80) // Default width
@@ -424,17 +664,10 @@ func RunEditForm(hostName string, configFile string) error {
return err return err
} }
p := tea.NewProgram(editForm, tea.WithAltScreen()) m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run() _, err = p.Run()
if err != nil { return err
return err
}
if editForm.err != "" {
return fmt.Errorf(editForm.err)
}
return nil
} }
func (m *editFormModel) submitEditForm() tea.Cmd { func (m *editFormModel) submitEditForm() tea.Cmd {
@@ -453,12 +686,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
} }
// Get property values using direct indices // Get property values using direct indices
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput 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 // Set defaults
if port == "" { if port == "" {
@@ -478,7 +714,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
} }
// Parse tags // Parse tags
tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput tagsStr := strings.TrimSpace(m.inputs[7].Value()) // tagsInput
var tags []string var tags []string
if tagsStr != "" { if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") { for _, tag := range strings.Split(tagsStr, ",") {
@@ -491,13 +727,16 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Create the common host configuration // Create the common host configuration
commonHost := config.SSHHost{ commonHost := config.SSHHost{
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: options, ProxyCommand: proxyCommand,
Tags: tags, Options: options,
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
var err error var err error

View File

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

View File

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

View File

@@ -33,7 +33,8 @@ type Styles struct {
HelpText lipgloss.Style HelpText lipgloss.Style
// Error and confirmation styles // Error and confirmation styles
Error lipgloss.Style Error lipgloss.Style
ErrorText lipgloss.Style
// Form styles (for add/edit forms) // Form styles (for add/edit forms)
FormTitle lipgloss.Style FormTitle lipgloss.Style
@@ -97,6 +98,11 @@ func NewStyles(width int) Styles {
BorderForeground(lipgloss.Color(ErrorColor)). BorderForeground(lipgloss.Color(ErrorColor)).
Padding(1, 2), Padding(1, 2),
// Error text style (no border, just red text)
ErrorText: lipgloss.NewStyle().
Foreground(lipgloss.Color(ErrorColor)).
Bold(true),
// Form styles // Form styles
FormTitle: lipgloss.NewStyle(). FormTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")). Foreground(lipgloss.Color("#FFFDF5")).

View File

@@ -16,7 +16,7 @@ import (
) )
// NewModel creates a new TUI model with the given SSH hosts // 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 // Load application configuration
appConfig, err := config.LoadAppConfig() appConfig, err := config.LoadAppConfig()
if err != nil { if err != nil {
@@ -38,7 +38,7 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
styles := NewStyles(80) // Default width styles := NewStyles(80) // Default width
// Initialize ping manager with 5 second timeout // Initialize ping manager with 5 second timeout
pingManager := connectivity.NewPingManager(5 * time.Second) pingManager := connectivity.NewPingManager(5*time.Second, configFile)
// Create the model with default sorting by name // Create the model with default sorting by name
m := Model{ m := Model{
@@ -54,6 +54,7 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
height: 24, height: 24,
ready: false, ready: false,
viewMode: ViewList, viewMode: ViewList,
searchMode: searchMode,
} }
// Sort hosts according to the default sort mode // 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.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50 ti.CharLimit = 50
ti.Width = 25 ti.Width = 25
if searchMode {
ti.Focus()
}
// Use dynamic column width calculation (will fallback to static if width not available) // Use dynamic column width calculation (will fallback to static if width not available)
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts) 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 // RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error { func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) error {
m := NewModel(hosts, configFile, currentVersion) m := NewModel(hosts, configFile, searchMode, currentVersion)
// Start the application in alt screen mode for clean output // Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen()) 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 { if m.deleteMode {
// Exit delete mode // Exit delete mode
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -508,15 +508,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else if m.deleteMode { } else if m.deleteMode {
// Confirm deletion // Confirm deletion
var err error var err error
if m.configFile != "" { if m.deleteHost != nil {
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile) err = config.DeleteSSHHostWithLine(*m.deleteHost)
} else {
err = config.DeleteSSHHost(m.deleteHost)
} }
if err != nil { if err != nil {
// Could display an error message here // Could display an error message here
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -533,7 +531,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if parseErr != nil { if parseErr != nil {
// Could display an error message here // Could display an error message here
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
@@ -548,7 +546,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableRows() m.updateTableRows()
m.deleteMode = false m.deleteMode = false
m.deleteHost = "" m.deleteHost = nil
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} else { } else {
@@ -673,11 +671,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d": case "d":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Delete the selected host // Delete the selected host
selected := m.table.SelectedRow() cursor := m.table.Cursor()
if len(selected) > 0 { if cursor >= 0 && cursor < len(m.filteredHosts) {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column // Get the host at the cursor position (which corresponds to filteredHosts index)
targetHost := &m.filteredHosts[cursor]
m.deleteMode = true m.deleteMode = true
m.deleteHost = hostName m.deleteHost = targetHost
m.table.Blur() m.table.Blur()
return m, nil return m, nil
} }

View File

@@ -144,7 +144,11 @@ func (m Model) renderListView() string {
func (m Model) renderDeleteConfirmation() string { func (m Model) renderDeleteConfirmation() string {
// Remove emojis (uncertain width depending on terminal) to stabilize the frame // Remove emojis (uncertain width depending on terminal) to stabilize the frame
title := "DELETE SSH HOST" 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." action := "This action cannot be undone."
help := "Enter: confirm • Esc: cancel" help := "Enter: confirm • Esc: cancel"

View File

@@ -11,6 +11,7 @@ import (
) )
// ValidateHostname checks if a hostname is valid // 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 { func ValidateHostname(hostname string) bool {
if len(hostname) == 0 || len(hostname) > 253 { if len(hostname) == 0 || len(hostname) > 253 {
return false return false
@@ -22,7 +23,18 @@ func ValidateHostname(hostname string) bool {
return false 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) return hostnameRegex.MatchString(hostname)
} }

View File

@@ -24,6 +24,19 @@ func TestValidateHostname(t *testing.T) {
{"hostname ending with dot", "example.com.", false}, {"hostname ending with dot", "example.com.", false},
{"hostname with hyphen", "my-server.com", true}, {"hostname with hyphen", "my-server.com", true},
{"hostname starting with number", "1example.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 { for _, tt := range tests {