feat: add automatic version update checking and notifications

- Add internal/version module for GitHub release checking
- Integrate async version check in Bubble Tea UI
- Display update notification in main interface
- Add version check to --version/-v command output
- Include comprehensive version comparison and error handling
- Add unit tests for version parsing and comparison logic
This commit is contained in:
Gu1llaum-3 2025-09-08 14:46:05 +02:00
parent ef075e74cf
commit 5c832ce26f
8 changed files with 331 additions and 15 deletions

View File

@ -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())
}

View File

@ -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

View File

@ -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())

View File

@ -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())

View File

@ -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

View File

@ -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)
}

145
internal/version/version.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}