From f189cb37e32f78920dae64bfbd9000b70e48a1d6 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Mon, 23 Feb 2026 21:28:54 +0100 Subject: [PATCH] feat: add --no-update-check flag and disable update check via config (issue #23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" --- README.md | 32 ++++++++-- cmd/root.go | 36 ++--------- .../config/{keybindings.go => appconfig.go} | 11 +++- ...{keybindings_test.go => appconfig_test.go} | 64 +++++++++++++++++++ internal/ui/tui.go | 12 +++- internal/ui/update.go | 4 +- 6 files changed, 118 insertions(+), 41 deletions(-) rename internal/config/{keybindings.go => appconfig.go} (91%) rename internal/config/{keybindings_test.go => appconfig_test.go} (73%) diff --git a/README.md b/README.md index ac205eb..ef8ed5e 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,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.user' -# Show version information (includes update check) +# Show version information sshm --version +# Disable automatic update check (useful on air-gapped machines) +sshm --no-update-check + # Show help and available commands sshm --help ``` @@ -499,17 +502,31 @@ SSHM features asynchronous SSH connectivity checking that provides visual indica SSHM includes built-in version checking that notifies you of available updates: **Features:** -- **Background checking** - Version check happens asynchronously +- **Background checking** - Version check happens asynchronously, never blocking startup - **Release notifications** - Clear indicators when updates are available - **Pre-release detection** - Identifies beta and development versions - **GitHub integration** - Direct links to release pages - **Non-intrusive** - Updates don't interrupt your workflow +- **Configurable** - Can be disabled for air-gapped or offline environments **Update notifications appear:** - In the main TUI interface as a subtle notification -- In the `sshm --version` command output - 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 SSHM remembers your port forwarding configurations for easy reuse: @@ -663,9 +680,9 @@ This will be automatically converted to: 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:** - **Linux/macOS**: `~/.config/sshm/config.json` @@ -674,6 +691,7 @@ SSHM supports customizable key bindings through a configuration file. This is pa **Example Configuration:** ```json { + "check_for_updates": false, "key_bindings": { "quit_keys": ["q", "ctrl+c"], "disable_esc_quit": true @@ -682,12 +700,16 @@ SSHM supports customizable key bindings through a configuration file. This is pa ``` **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"]` - **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false` **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. +**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:** If no configuration file exists, SSHM will automatically create one with default settings that maintain backward compatibility. diff --git a/cmd/root.go b/cmd/root.go index d80b77b..896c616 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,19 +1,16 @@ package cmd import ( - "context" "fmt" "log" "os" "os/exec" "strings" "syscall" - "time" "github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/history" "github.com/Gu1llaum-3/sshm/internal/ui" - "github.com/Gu1llaum-3/sshm/internal/version" "github.com/spf13/cobra" ) @@ -30,6 +27,9 @@ var forceTTY bool // searchMode enables the focus on search mode at startup 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 var RootCmd = &cobra.Command{ Use: "sshm [host] [command...]", @@ -143,7 +143,7 @@ func runInteractiveMode() { } // 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) } } @@ -213,30 +213,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. func Execute() { if err := RootCmd.Execute(); err != nil { @@ -258,7 +234,7 @@ func init() { 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") + RootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable automatic update check") - // Set custom version template with update check - RootCmd.SetVersionTemplate(getVersionWithUpdateCheck()) + RootCmd.SetVersionTemplate("{{.Name}} version {{.Version}}\n") } diff --git a/internal/config/keybindings.go b/internal/config/appconfig.go similarity index 91% rename from internal/config/keybindings.go rename to internal/config/appconfig.go index 0f2d40d..fa684e8 100644 --- a/internal/config/keybindings.go +++ b/internal/config/appconfig.go @@ -18,7 +18,16 @@ type KeyBindings struct { // AppConfig represents the main application configuration type AppConfig struct { - KeyBindings KeyBindings `json:"key_bindings"` + CheckForUpdates *bool `json:"check_for_updates,omitempty"` + 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 diff --git a/internal/config/keybindings_test.go b/internal/config/appconfig_test.go similarity index 73% rename from internal/config/keybindings_test.go rename to internal/config/appconfig_test.go index 620b92f..f77fbf1 100644 --- a/internal/config/keybindings_test.go +++ b/internal/config/appconfig_test.go @@ -104,6 +104,58 @@ func TestAppConfigBasics(t *testing.T) { if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { 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) { @@ -141,6 +193,7 @@ func TestSaveAndLoadAppConfigIntegration(t *testing.T) { configPath := filepath.Join(tempDir, "config.json") customConfig := AppConfig{ + CheckForUpdates: boolPtr(false), KeyBindings: KeyBindings{ QuitKeys: []string{"q"}, DisableEscQuit: true, @@ -178,4 +231,15 @@ func TestSaveAndLoadAppConfigIntegration(t *testing.T) { if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" { 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") + } } \ No newline at end of file diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 84e4581..e621555 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -16,7 +16,7 @@ import ( ) // 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 appConfig, err := config.LoadAppConfig() if err != nil { @@ -26,6 +26,12 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren appConfig = &defaultConfig } + // CLI flag overrides config file setting + if noUpdateCheck { + f := false + appConfig.CheckForUpdates = &f + } + // Initialize the history manager historyManager, err := history.NewHistoryManager() if err != nil { @@ -151,8 +157,8 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren } // RunInteractiveMode starts the interactive TUI interface -func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string) error { - m := NewModel(hosts, configFile, searchMode, currentVersion) +func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) error { + m := NewModel(hosts, configFile, searchMode, currentVersion, noUpdateCheck) // Start the application in alt screen mode for clean output p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/internal/ui/update.go b/internal/ui/update.go index a7c4aee..f59482a 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -74,8 +74,8 @@ func (m Model) Init() tea.Cmd { // Basic initialization commands cmds = append(cmds, textinput.Blink) - // Check for version updates if we have a current version - if m.currentVersion != "" { + // Check for version updates if we have a current version and updates are enabled + if m.currentVersion != "" && m.appConfig.IsUpdateCheckEnabled() { cmds = append(cmds, checkVersionCmd(m.currentVersion)) }