4 Commits

Author SHA1 Message Date
9c639206f7 feat: add hidden tag to hide hosts from TUI and search
Hosts tagged with "hidden" are excluded from the TUI list, shell
completions, and sshm search. Direct connections via sshm <host>
still work regardless of the tag.

A toggle key (H) shows or hides hidden hosts in the TUI, with a
yellow banner indicating the active state. The key is documented
in the help panel (h).

A contextual hint on the Tags field in the add and edit forms
reminds the user that "hidden" hides the host from the list.
2026-02-25 20:27:22 +01:00
838941e3eb fix: allow env vars and SSH tokens in IdentityFile validation (issue #33)
ValidateIdentityFile now accepts $VAR/${VAR} (expanded via os.Expand, undefined vars accepted as-is) and SSH tokens like %d, %h before falling back to os.Stat.
The raw value is preserved when writing to ssh_config.
2026-02-23 23:04:40 +01:00
2a1f6d5449 fix: replace sshm process with ssh via syscall.Exec (issue #41)
When running `sshm <host>`, the sshm process was staying alive as a parent for the entire SSH session.
History is recorded before SSH starts, so the parent process served no purpose.

Use syscall.Exec() to replace the sshm process in-place with ssh, keeping the same PID. Falls back to exec.Command() on Windows where syscall.Exec is not supported.
2026-02-23 21:48:41 +01:00
f189cb37e3 feat: add --no-update-check flag and disable update check via config (issue #23)
Add support for disabling the automatic update check at startup, which could cause delays on air-gapped or offline machines due to DNS timeouts.

- Add --no-update-check CLI flag for one-time override
- Add check_for_updates field (*bool) to AppConfig with default true
- CLI flag overrides the config file setting (both feed into IsUpdateCheckEnabled)
- Move update check from --version template to TUI Init() only, respecting the new configuration
- Remove getVersionWithUpdateCheck() from cmd/root.go; --version now prints a plain version string
- Rename internal/config/keybindings.go → appconfig.go and keybindings_test.go → appconfig_test.go to reflect the broader scope of the file
- Add TestIsUpdateCheckEnabled with table-driven cases (nil config, nil field, true, false) and extend existing integration test with a CheckForUpdates round-trip
- Update README: document --no-update-check flag, config option, and rename "Custom Key Bindings" section to "Application Configuration"
2026-02-23 21:28:54 +01:00
15 changed files with 250 additions and 52 deletions

View File

@@ -30,7 +30,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>` - **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>`
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence - **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence
- **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly - **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization - **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization; use the special `hidden` tag to exclude hosts from the list while keeping them connectable
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications - **🔔 Smart Updates** - Automatic version checking with update notifications
@@ -106,6 +106,7 @@ sshm
- `d` - Delete selected host - `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives) - `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup - `f` - Port forwarding setup
- `H` - Toggle hidden hosts visibility
- `q` - Quit - `q` - Quit
- `/` - Search/filter hosts - `/` - Search/filter hosts
@@ -279,9 +280,12 @@ sshm -c /path/to/custom/ssh_config info prod-server
sshm info prod-server | jq -r '.result.target.hostname' sshm info prod-server | jq -r '.result.target.hostname'
sshm info prod-server | jq -r '.result.target.user' sshm info prod-server | jq -r '.result.target.user'
# Show version information (includes update check) # Show version information
sshm --version sshm --version
# Disable automatic update check (useful on air-gapped machines)
sshm --no-update-check
# Show help and available commands # Show help and available commands
sshm --help sshm --help
``` ```
@@ -499,17 +503,31 @@ SSHM features asynchronous SSH connectivity checking that provides visual indica
SSHM includes built-in version checking that notifies you of available updates: SSHM includes built-in version checking that notifies you of available updates:
**Features:** **Features:**
- **Background checking** - Version check happens asynchronously - **Background checking** - Version check happens asynchronously, never blocking startup
- **Release notifications** - Clear indicators when updates are available - **Release notifications** - Clear indicators when updates are available
- **Pre-release detection** - Identifies beta and development versions - **Pre-release detection** - Identifies beta and development versions
- **GitHub integration** - Direct links to release pages - **GitHub integration** - Direct links to release pages
- **Non-intrusive** - Updates don't interrupt your workflow - **Non-intrusive** - Updates don't interrupt your workflow
- **Configurable** - Can be disabled for air-gapped or offline environments
**Update notifications appear:** **Update notifications appear:**
- In the main TUI interface as a subtle notification - In the main TUI interface as a subtle notification
- In the `sshm --version` command output
- Only when a newer stable version is available - Only when a newer stable version is available
**Disabling update checks:**
Via the CLI flag (one-time):
```bash
sshm --no-update-check
```
Via `~/.config/sshm/config.json` (persistent):
```json
{
"check_for_updates": false
}
```
#### Port Forwarding History #### Port Forwarding History
SSHM remembers your port forwarding configurations for easy reuse: SSHM remembers your port forwarding configurations for easy reuse:
@@ -630,7 +648,7 @@ SSHM supports all standard SSH configuration options:
- `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`) - `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`)
- `Tags` - Custom tags (SSHM extension) - `Tags` - Custom tags (SSHM extension); the special tag `hidden` hides the host from the TUI and `sshm search` while keeping it connectable via `sshm <host>`
**Additional SSH Options:** **Additional SSH Options:**
You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format. You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format.
@@ -663,9 +681,9 @@ This will be automatically converted to:
StrictHostKeyChecking no StrictHostKeyChecking no
``` ```
### Custom Key Bindings ### Application Configuration
SSHM supports customizable key bindings through a configuration file. This is particularly useful for users who want to modify the default quit behavior. SSHM supports a configuration file to customize its behavior, including key bindings and update checking.
**Configuration File Location:** **Configuration File Location:**
- **Linux/macOS**: `~/.config/sshm/config.json` - **Linux/macOS**: `~/.config/sshm/config.json`
@@ -674,6 +692,7 @@ SSHM supports customizable key bindings through a configuration file. This is pa
**Example Configuration:** **Example Configuration:**
```json ```json
{ {
"check_for_updates": false,
"key_bindings": { "key_bindings": {
"quit_keys": ["q", "ctrl+c"], "quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": true "disable_esc_quit": true
@@ -682,12 +701,16 @@ SSHM supports customizable key bindings through a configuration file. This is pa
``` ```
**Available Options:** **Available Options:**
- **check_for_updates**: Boolean to enable or disable the automatic update check at startup. Default: `true`. Set to `false` on air-gapped or offline machines to avoid connection delays.
- **quit_keys**: Array of keys that will quit the application. Default: `["q", "ctrl+c"]` - **quit_keys**: Array of keys that will quit the application. Default: `["q", "ctrl+c"]`
- **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false` - **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false`
**For Vim Users:** **For Vim Users:**
If you frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`. This will disable ESC as a quit key while preserving all other functionality. If you frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`. This will disable ESC as a quit key while preserving all other functionality.
**For Air-gapped Machines:**
If SSHM is slow to start due to DNS timeouts when reaching GitHub, set `check_for_updates` to `false`. You can also use the `--no-update-check` CLI flag for a one-time override without editing the config file.
**Default Configuration:** **Default Configuration:**
If no configuration file exists, SSHM will automatically create one with default settings that maintain backward compatibility. If no configuration file exists, SSHM will automatically create one with default settings that maintain backward compatibility.

View File

@@ -1,19 +1,16 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history" "github.com/Gu1llaum-3/sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/ui" "github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -30,6 +27,9 @@ var forceTTY bool
// searchMode enables the focus on search mode at startup // searchMode enables the focus on search mode at startup
var searchMode bool var searchMode bool
// noUpdateCheck disables the async update check in the TUI
var noUpdateCheck 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] [command...]", Use: "sshm [host] [command...]",
@@ -75,6 +75,8 @@ Examples:
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
hosts = config.FilterVisibleHosts(hosts)
var completions []string var completions []string
toCompleteLower := strings.ToLower(toComplete) toCompleteLower := strings.ToLower(toComplete)
for _, host := range hosts { for _, host := range hosts {
@@ -143,7 +145,7 @@ func runInteractiveMode() {
} }
// Run the interactive TUI // Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile, searchMode, AppVersion); err != nil { if err := ui.RunInteractiveMode(hosts, configFile, searchMode, AppVersion, noUpdateCheck); err != nil {
log.Fatalf("Error running interactive mode: %v", err) log.Fatalf("Error running interactive mode: %v", err)
} }
} }
@@ -196,6 +198,15 @@ func connectToHost(hostName string, remoteCommand []string) {
fmt.Printf("Connecting to %s...\n", hostName) fmt.Printf("Connecting to %s...\n", hostName)
} }
sshPath, lookErr := exec.LookPath("ssh")
if lookErr == nil {
argv := append([]string{"ssh"}, args...)
// On Unix, Exec replaces the process and never returns on success.
// On Windows, Exec is not supported and returns an error; fall through to the exec.Command fallback.
_ = syscall.Exec(sshPath, argv, os.Environ())
}
// Fallback for Windows or if LookPath failed
sshCmd := exec.Command("ssh", args...) sshCmd := exec.Command("ssh", args...)
sshCmd.Stdin = os.Stdin sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout sshCmd.Stdout = os.Stdout
@@ -213,30 +224,6 @@ func connectToHost(hostName string, remoteCommand []string) {
} }
} }
// getVersionWithUpdateCheck returns a custom version string with update check
func getVersionWithUpdateCheck() string {
versionText := fmt.Sprintf("sshm version %s", AppVersion)
// Check for updates
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
updateInfo, err := version.CheckForUpdates(ctx, AppVersion)
if err != nil {
// Return just version if check fails
return versionText + "\n"
}
if updateInfo != nil && updateInfo.Available {
versionText += fmt.Sprintf("\n🚀 Update available: %s → %s (%s)",
updateInfo.CurrentVer,
updateInfo.LatestVer,
updateInfo.ReleaseURL)
}
return versionText + "\n"
}
// 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() {
if err := RootCmd.Execute(); err != nil { if err := RootCmd.Execute(); err != nil {
@@ -258,7 +245,7 @@ func init() {
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.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") RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup")
RootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable automatic update check")
// Set custom version template with update check RootCmd.SetVersionTemplate("{{.Name}} version {{.Version}}\n")
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())
} }

View File

@@ -55,6 +55,9 @@ func runSearch(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Filter out hidden hosts
hosts = config.FilterVisibleHosts(hosts)
// Get search query // Get search query
var query string var query string
if len(args) > 0 { if len(args) > 0 {

View File

@@ -18,9 +18,18 @@ type KeyBindings struct {
// AppConfig represents the main application configuration // AppConfig represents the main application configuration
type AppConfig struct { type AppConfig struct {
CheckForUpdates *bool `json:"check_for_updates,omitempty"`
KeyBindings KeyBindings `json:"key_bindings"` KeyBindings KeyBindings `json:"key_bindings"`
} }
// IsUpdateCheckEnabled returns true if the update check is enabled (default: true)
func (c *AppConfig) IsUpdateCheckEnabled() bool {
if c == nil || c.CheckForUpdates == nil {
return true
}
return *c.CheckForUpdates
}
// GetDefaultKeyBindings returns the default key bindings configuration // GetDefaultKeyBindings returns the default key bindings configuration
func GetDefaultKeyBindings() KeyBindings { func GetDefaultKeyBindings() KeyBindings {
return KeyBindings{ return KeyBindings{

View File

@@ -104,6 +104,58 @@ func TestAppConfigBasics(t *testing.T) {
if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) {
t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(defaultConfig.KeyBindings.QuitKeys)) t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(defaultConfig.KeyBindings.QuitKeys))
} }
// CheckForUpdates should be nil by default
if defaultConfig.CheckForUpdates != nil {
t.Error("Default configuration should have CheckForUpdates as nil")
}
// IsUpdateCheckEnabled should return true by default
if !defaultConfig.IsUpdateCheckEnabled() {
t.Error("IsUpdateCheckEnabled should return true when CheckForUpdates is nil")
}
}
func boolPtr(b bool) *bool {
return &b
}
func TestIsUpdateCheckEnabled(t *testing.T) {
tests := []struct {
name string
config *AppConfig
expected bool
}{
{
name: "nil AppConfig returns true",
config: nil,
expected: true,
},
{
name: "CheckForUpdates nil returns true",
config: &AppConfig{},
expected: true,
},
{
name: "CheckForUpdates true returns true",
config: &AppConfig{CheckForUpdates: boolPtr(true)},
expected: true,
},
{
name: "CheckForUpdates false returns false",
config: &AppConfig{CheckForUpdates: boolPtr(false)},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.IsUpdateCheckEnabled()
if result != tt.expected {
t.Errorf("IsUpdateCheckEnabled() = %v, expected %v", result, tt.expected)
}
})
}
} }
func TestMergeWithDefaults(t *testing.T) { func TestMergeWithDefaults(t *testing.T) {
@@ -141,6 +193,7 @@ func TestSaveAndLoadAppConfigIntegration(t *testing.T) {
configPath := filepath.Join(tempDir, "config.json") configPath := filepath.Join(tempDir, "config.json")
customConfig := AppConfig{ customConfig := AppConfig{
CheckForUpdates: boolPtr(false),
KeyBindings: KeyBindings{ KeyBindings: KeyBindings{
QuitKeys: []string{"q"}, QuitKeys: []string{"q"},
DisableEscQuit: true, DisableEscQuit: true,
@@ -178,4 +231,15 @@ func TestSaveAndLoadAppConfigIntegration(t *testing.T) {
if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" { if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" {
t.Errorf("Expected quit keys to be ['q'], got %v", loadedConfig.KeyBindings.QuitKeys) t.Errorf("Expected quit keys to be ['q'], got %v", loadedConfig.KeyBindings.QuitKeys)
} }
// Verify CheckForUpdates is correctly persisted and reloaded
if loadedConfig.CheckForUpdates == nil {
t.Fatal("CheckForUpdates should not be nil after round-trip")
}
if *loadedConfig.CheckForUpdates != false {
t.Errorf("CheckForUpdates should be false after round-trip, got %v", *loadedConfig.CheckForUpdates)
}
if loadedConfig.IsUpdateCheckEnabled() {
t.Error("IsUpdateCheckEnabled should return false when CheckForUpdates is false")
}
} }

View File

@@ -1624,6 +1624,27 @@ func GetAllConfigFiles() ([]string, error) {
return files, nil return files, nil
} }
// FilterVisibleHosts returns only hosts that do not have the "hidden" tag.
func FilterVisibleHosts(hosts []SSHHost) []SSHHost {
var visible []SSHHost
for _, h := range hosts {
if !hostHasTag(h.Tags, "hidden") {
visible = append(visible, h)
}
}
return visible
}
// hostHasTag reports whether the given tag list contains the target tag (case-insensitive).
func hostHasTag(tags []string, target string) bool {
for _, t := range tags {
if strings.EqualFold(t, target) {
return true
}
}
return false
}
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file // GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) { func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
if baseConfigPath == "" { if baseConfigPath == "" {

View File

@@ -438,7 +438,12 @@ func (m *addFormModel) renderGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n") b.WriteString("\n")
if field.index == tagsInput && m.focused == tagsInput {
b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`))
b.WriteString("\n")
}
b.WriteString("\n")
} }
return b.String() return b.String()

View File

@@ -599,7 +599,12 @@ func (m *editFormModel) renderEditGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n") b.WriteString("\n")
if field.index == 7 && m.focusArea == focusAreaProperties && m.focused == 7 {
b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`))
b.WriteString("\n")
}
b.WriteString("\n")
} }
return b.String() return b.String()

View File

@@ -81,6 +81,9 @@ func (m *helpModel) View() string {
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "), m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")), m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("H "),
m.styles.HelpText.Render("toggle hidden hosts visibility")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "), m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")), m.styles.HelpText.Render("setup port forwarding")),

View File

@@ -70,8 +70,10 @@ func (p PortForwardType) String() string {
type Model struct { type Model struct {
table table.Model table table.Model
searchInput textinput.Model searchInput textinput.Model
hosts []config.SSHHost allHosts []config.SSHHost // all parsed hosts, including hidden ones
hosts []config.SSHHost // visible hosts (filtered by showHidden)
filteredHosts []config.SSHHost filteredHosts []config.SSHHost
showHidden bool // when true, hidden-tagged hosts are shown
searchMode bool searchMode bool
deleteMode bool deleteMode bool
deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
@@ -108,6 +110,14 @@ type Model struct {
showingError bool showingError bool
} }
// applyVisibilityFilter returns hosts filtered according to the showHidden flag.
func (m Model) applyVisibilityFilter(hosts []config.SSHHost) []config.SSHHost {
if m.showHidden {
return hosts
}
return config.FilterVisibleHosts(hosts)
}
// updateTableStyles updates the table header border color based on focus state // updateTableStyles updates the table header border color based on focus state
func (m *Model) updateTableStyles() { func (m *Model) updateTableStyles() {
s := table.DefaultStyles() s := table.DefaultStyles()

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 string, searchMode bool, currentVersion string) Model { func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) Model {
// Load application configuration // Load application configuration
appConfig, err := config.LoadAppConfig() appConfig, err := config.LoadAppConfig()
if err != nil { if err != nil {
@@ -26,6 +26,12 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
appConfig = &defaultConfig appConfig = &defaultConfig
} }
// CLI flag overrides config file setting
if noUpdateCheck {
f := false
appConfig.CheckForUpdates = &f
}
// Initialize the history manager // Initialize the history manager
historyManager, err := history.NewHistoryManager() historyManager, err := history.NewHistoryManager()
if err != nil { if err != nil {
@@ -42,7 +48,7 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
// Create the model with default sorting by name // Create the model with default sorting by name
m := Model{ m := Model{
hosts: hosts, allHosts: hosts,
historyManager: historyManager, historyManager: historyManager,
pingManager: pingManager, pingManager: pingManager,
sortMode: SortByName, sortMode: SortByName,
@@ -57,8 +63,12 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
searchMode: searchMode, searchMode: searchMode,
} }
// Apply visibility filter (showHidden is false by default)
visibleHosts := m.applyVisibilityFilter(hosts)
m.hosts = visibleHosts
// Sort hosts according to the default sort mode // Sort hosts according to the default sort mode
sortedHosts := m.sortHosts(hosts) sortedHosts := m.sortHosts(visibleHosts)
// Create the search input // Create the search input
ti := textinput.New() ti := textinput.New()
@@ -151,8 +161,8 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
} }
// RunInteractiveMode starts the interactive TUI interface // RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) error { func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) error {
m := NewModel(hosts, configFile, searchMode, currentVersion) m := NewModel(hosts, configFile, searchMode, currentVersion, noUpdateCheck)
// 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

@@ -74,8 +74,8 @@ func (m Model) Init() tea.Cmd {
// Basic initialization commands // Basic initialization commands
cmds = append(cmds, textinput.Blink) cmds = append(cmds, textinput.Blink)
// Check for version updates if we have a current version // Check for version updates if we have a current version and updates are enabled
if m.currentVersion != "" { if m.currentVersion != "" && m.appConfig.IsUpdateCheckEnabled() {
cmds = append(cmds, checkVersionCmd(m.currentVersion)) cmds = append(cmds, checkVersionCmd(m.currentVersion))
} }
@@ -187,7 +187,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -231,7 +232,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -276,7 +278,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -535,7 +538,8 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -705,6 +709,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewMode = ViewHelp m.viewMode = ViewHelp
return m, nil return m, nil
} }
case "H":
if !m.searchMode && !m.deleteMode {
// Toggle visibility of hidden hosts
m.showHidden = !m.showHidden
m.hosts = m.sortHosts(m.applyVisibilityFilter(m.allHosts))
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
return m, nil
}
case "s": case "s":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now) // Cycle through sort modes (only 2 modes now)

View File

@@ -86,6 +86,14 @@ func (m Model) renderListView() string {
components = append(components, errorStyle.Render("❌ "+m.errorMessage)) components = append(components, errorStyle.Render("❌ "+m.errorMessage))
} }
// Add indicator when hidden hosts are shown
if m.showHidden {
hiddenBannerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).
Bold(true)
components = append(components, hiddenBannerStyle.Render(" [showing hidden hosts — press H to hide]"))
}
// Add the search bar with the appropriate style based on focus // Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): " searchPrompt := "Search (/ to focus): "
if m.searchMode { if m.searchMode {

View File

@@ -66,6 +66,25 @@ func ValidateIdentityFile(path string) bool {
if path == "" { if path == "" {
return true // Optional field return true // Optional field
} }
// SSH tokens (e.g. %d, %h, %r, %u) are resolved by SSH at connection time
sshTokenRegex := regexp.MustCompile(`%[hprunCdiklLT]`)
if sshTokenRegex.MatchString(path) {
return true
}
// Expand environment variables ($VAR and ${VAR}); track undefined ones
hasUndefined := false
path = os.Expand(path, func(key string) string {
val, ok := os.LookupEnv(key)
if !ok {
hasUndefined = true
return "$" + key
}
return val
})
// If any variable was undefined, accept the path (SSH will report the error)
if hasUndefined {
return true
}
// Expand ~ to home directory // Expand ~ to home directory
if strings.HasPrefix(path, "~/") { if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()

View File

@@ -133,6 +133,9 @@ func TestValidateIdentityFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Set up an env var pointing to the valid file's directory for env var tests
t.Setenv("TEST_SSHM_DIR", tmpDir)
tests := []struct { tests := []struct {
name string name string
path string path string
@@ -143,6 +146,13 @@ func TestValidateIdentityFile(t *testing.T) {
{"non-existent file", "/path/to/nonexistent", false}, {"non-existent file", "/path/to/nonexistent", false},
// Skip tilde path test in CI environments where ~/.ssh/id_rsa may not exist // Skip tilde path test in CI environments where ~/.ssh/id_rsa may not exist
// {"tilde path", "~/.ssh/id_rsa", true}, // Will pass if file exists // {"tilde path", "~/.ssh/id_rsa", true}, // Will pass if file exists
// Environment variable expansion (issue #33)
{"env var $VAR/key defined", "$TEST_SSHM_DIR/test_key", true},
{"env var ${VAR}/key defined", "${TEST_SSHM_DIR}/test_key", true},
{"env var undefined", "$UNDEFINED_SSHM_VAR_XYZ/key", true},
// SSH tokens
{"SSH token %d", "%d/.ssh/id_rsa", true},
{"SSH token %h", "%h-key", true},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -170,6 +180,7 @@ func TestValidateHost(t *testing.T) {
if err := os.WriteFile(validIdentity, []byte("test"), 0600); err != nil { if err := os.WriteFile(validIdentity, []byte("test"), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Setenv("TEST_SSHM_HOST_DIR", tmpDir)
tests := []struct { tests := []struct {
name string name string
@@ -187,6 +198,9 @@ func TestValidateHost(t *testing.T) {
{"invalid hostname", "myserver", "invalid..hostname", "22", "", true}, {"invalid hostname", "myserver", "invalid..hostname", "22", "", true},
{"invalid port", "myserver", "example.com", "99999", "", true}, {"invalid port", "myserver", "example.com", "99999", "", true},
{"invalid identity", "myserver", "example.com", "22", "/nonexistent", true}, {"invalid identity", "myserver", "example.com", "22", "/nonexistent", true},
// Environment variables and SSH tokens in identity (issue #33)
{"identity with env var", "myserver", "example.com", "22", "$TEST_SSHM_HOST_DIR/test_key", false},
{"identity with SSH token", "myserver", "example.com", "22", "%d/.ssh/id_rsa", false},
} }
for _, tt := range tests { for _, tt := range tests {