diff --git a/cmd/root.go b/cmd/root.go index 8daf41b..a93bc91 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,19 +1,22 @@ package cmd import ( + "context" "fmt" "log" "os" "strings" + "time" "github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/ui" + "github.com/Gu1llaum-3/sshm/internal/version" "github.com/spf13/cobra" ) -// version will be set at build time via -ldflags -var version = "dev" +// appVersion will be set at build time via -ldflags +var appVersion = "dev" // configFile holds the path to the SSH config file var configFile string @@ -29,7 +32,7 @@ Main usage: You can also use sshm in CLI mode for direct operations. Hosts are read from your ~/.ssh/config file by default.`, - Version: version, + Version: appVersion, Run: func(cmd *cobra.Command, args []string) { // If no arguments provided, run interactive mode if len(args) == 0 { @@ -85,7 +88,7 @@ func runInteractiveMode() { } // Run the interactive TUI - if err := ui.RunInteractiveMode(hosts, configFile); err != nil { + if err := ui.RunInteractiveMode(hosts, configFile, appVersion); err != nil { log.Fatalf("Error running interactive mode: %v", err) } } @@ -136,6 +139,30 @@ func connectToHost(hostName string) { fmt.Printf("%s\n", strings.Join(sshCmd, " ")) } +// 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 { @@ -147,4 +174,7 @@ func Execute() { func init() { // Add the config file flag rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") + + // Set custom version template with update check + rootCmd.SetVersionTemplate(getVersionWithUpdateCheck()) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 38194bb..18b953d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -4,6 +4,7 @@ import ( "github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/connectivity" "github.com/Gu1llaum-3/sshm/internal/history" + "github.com/Gu1llaum-3/sshm/internal/version" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" @@ -78,6 +79,10 @@ type Model struct { sortMode SortMode configFile string // Path to the SSH config file + // Version update information + updateInfo *version.UpdateInfo + currentVersion string + // View management viewMode ViewMode addForm *addFormModel diff --git a/internal/ui/table.go b/internal/ui/table.go index 9e5287e..e80c0a6 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -178,12 +178,13 @@ func (m *Model) updateTableHeight() { // Calculate dynamic table height based on terminal size // Layout breakdown: // - ASCII title: 5 lines (1 empty + 4 text lines) + // - Update banner : 1 line (if present) // - Search bar: 1 line // - Help text: 1 line // - App margins/spacing: 3 lines // - Safety margin: 3 lines (to ensure UI elements are always visible) - // Total reserved: 13 lines minimum to preserve essential UI elements - reservedHeight := 13 + // Total reserved: 14 lines minimum to preserve essential UI elements + reservedHeight := 14 availableHeight := m.height - reservedHeight hostCount := len(m.table.Rows()) diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 4ca3666..bf1b652 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) Model { +func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { // Initialize the history manager historyManager, err := history.NewHistoryManager() if err != nil { @@ -38,6 +38,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { pingManager: pingManager, sortMode: SortByName, configFile: configFile, + currentVersion: currentVersion, styles: styles, width: 80, height: 24, @@ -136,8 +137,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { } // RunInteractiveMode starts the interactive TUI interface -func RunInteractiveMode(hosts []config.SSHHost, configFile string) error { - m := NewModel(hosts, configFile) +func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error { + m := NewModel(hosts, configFile, currentVersion) // 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 d066fa4..24c003e 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -8,14 +8,17 @@ import ( "github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/connectivity" + "github.com/Gu1llaum-3/sshm/internal/version" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) -// Messages for SSH ping functionality +// Messages for SSH ping functionality and version checking type ( - pingResultMsg *connectivity.HostPingResult + pingResultMsg *connectivity.HostPingResult + versionCheckMsg *version.UpdateInfo + versionErrorMsg error ) // startPingAllCmd creates a command to ping all hosts concurrently @@ -49,12 +52,33 @@ func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHos } } +// checkVersionCmd creates a command to check for version updates +func checkVersionCmd(currentVersion string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + updateInfo, err := version.CheckForUpdates(ctx, currentVersion) + if err != nil { + return versionErrorMsg(err) + } + return versionCheckMsg(updateInfo) + } +} + // Init initializes the model func (m Model) Init() tea.Cmd { - return tea.Batch( - textinput.Blink, - // Ping is now optional - use 'p' key to start ping - ) + var cmds []tea.Cmd + + // Basic initialization commands + cmds = append(cmds, textinput.Blink) + + // Check for version updates if we have a current version + if m.currentVersion != "" { + cmds = append(cmds, checkVersionCmd(m.currentVersion)) + } + + return tea.Batch(cmds...) } // Update handles model updates @@ -115,6 +139,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case versionCheckMsg: + // Handle version check result + if msg != nil { + m.updateInfo = msg + } + return m, nil + + case versionErrorMsg: + // Handle version check error (silently - not critical) + // We don't want to show error messages for version checks + // as it might disrupt the user experience + return m, nil + case addFormSubmitMsg: if msg.err != nil { // Show error in form diff --git a/internal/ui/view.go b/internal/ui/view.go index 274403a..9afe16c 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -54,6 +54,20 @@ func (m Model) renderListView() string { // Add the ASCII title components = append(components, m.styles.Header.Render(asciiTitle)) + // Add update notification if available (between title and search) + if m.updateInfo != nil && m.updateInfo.Available { + updateText := fmt.Sprintf("šŸš€ Update available: %s → %s", + m.updateInfo.CurrentVer, + m.updateInfo.LatestVer) + + updateStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")). // Green color + Bold(true). + Align(lipgloss.Center) // Center the notification + + components = append(components, updateStyle.Render(updateText)) + } + // Add the search bar with the appropriate style based on focus searchPrompt := "Search (/ to focus): " if m.searchMode { @@ -157,3 +171,30 @@ func (m Model) renderDeleteConfirmation() string { return box.Render(raw) } + +// renderUpdateNotification renders the update notification banner +func (m Model) renderUpdateNotification() string { + if m.updateInfo == nil || !m.updateInfo.Available { + return "" + } + + // Create the notification message + message := fmt.Sprintf("šŸš€ Update available: %s → %s", + m.updateInfo.CurrentVer, + m.updateInfo.LatestVer) + + // Add release URL if available + if m.updateInfo.ReleaseURL != "" { + message += fmt.Sprintf(" • View release: %s", m.updateInfo.ReleaseURL) + } + + // Style the notification with a bright color to make it stand out + notificationStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF00")). // Bright green + Bold(true). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00AA00")) // Darker green border + + return notificationStyle.Render(message) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..7b9906f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,145 @@ +package version + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// GitHubRelease represents a GitHub release response +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// UpdateInfo contains information about available updates +type UpdateInfo struct { + Available bool + CurrentVer string + LatestVer string + ReleaseURL string + ReleaseName string +} + +// parseVersion extracts version numbers from a version string (e.g., "v1.2.3" -> [1, 2, 3]) +func parseVersion(version string) []int { + // Remove 'v' prefix if present + version = strings.TrimPrefix(version, "v") + + parts := strings.Split(version, ".") + nums := make([]int, len(parts)) + + for i, part := range parts { + // Remove any non-numeric suffixes (e.g., "1-beta", "2-rc1") + numPart := strings.FieldsFunc(part, func(r rune) bool { + return r == '-' || r == '+' || r == '_' + })[0] + + if num, err := strconv.Atoi(numPart); err == nil { + nums[i] = num + } + } + + return nums +} + +// compareVersions compares two version strings +// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + nums1 := parseVersion(v1) + nums2 := parseVersion(v2) + + // Pad with zeros to make lengths equal + maxLen := len(nums1) + if len(nums2) > maxLen { + maxLen = len(nums2) + } + + for len(nums1) < maxLen { + nums1 = append(nums1, 0) + } + for len(nums2) < maxLen { + nums2 = append(nums2, 0) + } + + // Compare each part + for i := 0; i < maxLen; i++ { + if nums1[i] < nums2[i] { + return -1 + } + if nums1[i] > nums2[i] { + return 1 + } + } + + return 0 +} + +// CheckForUpdates checks GitHub for the latest release of sshm +func CheckForUpdates(ctx context.Context, currentVersion string) (*UpdateInfo, error) { + // Skip version check if current version is "dev" + if currentVersion == "dev" { + return &UpdateInfo{ + Available: false, + CurrentVer: currentVersion, + }, nil + } + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set user agent + req.Header.Set("User-Agent", "sshm/"+currentVersion) + + // Make the request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + // Parse the response + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Skip pre-releases and drafts + if release.Prerelease || release.Draft { + return &UpdateInfo{ + Available: false, + CurrentVer: currentVersion, + }, nil + } + + // Compare versions + updateAvailable := compareVersions(currentVersion, release.TagName) < 0 + + return &UpdateInfo{ + Available: updateAvailable, + CurrentVer: currentVersion, + LatestVer: release.TagName, + ReleaseURL: release.HTMLURL, + ReleaseName: release.Name, + }, nil +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..9abf71a --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,56 @@ +package version + +import ( + "testing" +) + +func TestParseVersion(t *testing.T) { + tests := []struct { + version string + expected []int + }{ + {"v1.2.3", []int{1, 2, 3}}, + {"1.2.3", []int{1, 2, 3}}, + {"v2.0.0", []int{2, 0, 0}}, + {"1.2.3-beta", []int{1, 2, 3}}, + {"1.2.3-rc1", []int{1, 2, 3}}, + {"dev", []int{0}}, + } + + for _, test := range tests { + result := parseVersion(test.version) + if len(result) != len(test.expected) { + t.Errorf("parseVersion(%q) length = %d, want %d", test.version, len(result), len(test.expected)) + continue + } + for i, v := range result { + if v != test.expected[i] { + t.Errorf("parseVersion(%q)[%d] = %d, want %d", test.version, i, v, test.expected[i]) + break + } + } + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + {"v1.0.0", "v1.0.1", -1}, + {"v1.0.1", "v1.0.0", 1}, + {"v1.0.0", "v1.0.0", 0}, + {"1.2.3", "1.2.4", -1}, + {"2.0.0", "1.9.9", 1}, + {"1.2.3-beta", "1.2.3", 0}, // Should ignore suffixes + {"1.2.3", "1.2.3-rc1", 0}, + } + + for _, test := range tests { + result := compareVersions(test.v1, test.v2) + if result != test.expected { + t.Errorf("compareVersions(%q, %q) = %d, want %d", test.v1, test.v2, result, test.expected) + } + } +}