diff --git a/cmd/root.go b/cmd/root.go index f99b4dc..70dce17 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,18 @@ var rootCmd = &cobra.Command{ A minimal, developer-friendly time tracking tool that lives in your terminal. Track time effortlessly with automatic project detection and simple commands.`, - Version: Version, + Run: func(cmd *cobra.Command, args []string) { + // Check if version flag was set + versionFlag, _ := cmd.Flags().GetBool("version") + + if versionFlag { + DisplayVersionWithUpdateCheck() + return + } + + // Otherwise show help + cmd.Help() + }, } func Execute() { @@ -30,5 +41,5 @@ func Execute() { } func init() { - rootCmd.SetVersionTemplate(GetVersionOutput()) + rootCmd.Flags().BoolP("version", "v", false, "version for tmpo") } diff --git a/cmd/version.go b/cmd/version.go index ace92c6..0fd710d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -7,6 +7,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/DylanDevelops/tmpo/internal/update" "github.com/spf13/cobra" ) @@ -16,10 +17,17 @@ var versionCmd = &cobra.Command{ Long: "Display the current version information including date and release URL.", Hidden: true, Run: func(cmd *cobra.Command, args []string) { - fmt.Print(GetVersionOutput()) + DisplayVersionWithUpdateCheck() }, } +// DisplayVersionWithUpdateCheck displays the version information and checks for updates. +// This is the single source of truth for displaying version info with update notifications. +func DisplayVersionWithUpdateCheck() { + fmt.Print(GetVersionOutput()) + checkForUpdates() +} + // GetVersionOutput returns the formatted version string used by both // the version subcommand and the -v/--version flags func GetVersionOutput() string { @@ -32,10 +40,6 @@ func GetVersionOutput() string { // formatted as "MM-DD-YYYY" wrapped in parentheses (for example "(01-02-2006)"). // If inputDate is empty or cannot be parsed as RFC3339, it returns an empty string. func GetFormattedDate(inputDate string) string { - if inputDate == "" { - return "" - } - date, err := time.Parse(time.RFC3339, inputDate) if err != nil { return "" @@ -55,6 +59,26 @@ func GetChangelogUrl(version string) string { return fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) } +// checkForUpdates checks if a newer version is available and displays a message if so. +// It silently fails if there's no internet connection or if the check fails. +func checkForUpdates() { + // Only check if we have a valid version (not "dev" or empty) + if Version == "" || Version == "dev" { + return + } + + updateInfo, err := update.CheckForUpdate(Version) + if err != nil { + // Silently fail and don't bother the user with network errors + return + } + + if updateInfo.HasUpdate { + fmt.Printf("%s %s\n", ui.Info("New Update Available:"), ui.Bold(strings.TrimPrefix(updateInfo.LatestVersion, "v"))) + fmt.Printf("%s\n\n", ui.Muted(updateInfo.UpdateURL)) + } +} + func init() { rootCmd.AddCommand(versionCmd) } diff --git a/internal/update/checker.go b/internal/update/checker.go new file mode 100644 index 0000000..df4dc9f --- /dev/null +++ b/internal/update/checker.go @@ -0,0 +1,139 @@ +package update + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +const ( + githubAPIURL = "https://api.github.com/repos/DylanDevelops/tmpo/releases/latest" + checkTimeout = 3 * time.Second + connectTimeout = 2 * time.Second +) + +// ReleaseInfo represents the GitHub release information +type ReleaseInfo struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` +} + +// UpdateInfo contains information about available updates +type UpdateInfo struct { + CurrentVersion string + LatestVersion string + UpdateURL string + HasUpdate bool +} + +// IsConnectedToInternet performs a quick check to see if the user has internet connectivity. +// It tries to resolve a reliable DNS name (GitHub's) with a short timeout. +func IsConnectedToInternet() bool { + // Try to resolve GitHub's DNS with a 2-second timeout + _, err := net.LookupHost("api.github.com") + return err == nil +} + +// GetLatestVersion fetches the latest release version from GitHub API. +// It returns the version tag (e.g., "v1.2.3") and any error encountered. +func GetLatestVersion() (string, error) { + client := &http.Client{ + Timeout: checkTimeout, + } + + resp, err := client.Get(githubAPIURL) + if err != nil { + return "", fmt.Errorf("failed to fetch latest version: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var release ReleaseInfo + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to parse release info: %w", err) + } + + return release.TagName, nil +} + +// CompareVersions compares two semantic version strings. +// Returns: +// -1 if current < latest (update available) +// 0 if current == latest (up to date) +// 1 if current > latest (ahead of latest, e.g., dev build) +// +// Handles versions with or without "v" prefix (v1.2.3 or 1.2.3) +func CompareVersions(current, latest string) int { + // Remove "v" prefix if present + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + // Split into parts + currentParts := strings.Split(current, ".") + latestParts := strings.Split(latest, ".") + + // Compare each part + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + + for i := 0; i < maxLen; i++ { + var currentVal, latestVal int + + if i < len(currentParts) { + fmt.Sscanf(currentParts[i], "%d", ¤tVal) + } + if i < len(latestParts) { + fmt.Sscanf(latestParts[i], "%d", &latestVal) + } + + if currentVal < latestVal { + return -1 + } + if currentVal > latestVal { + return 1 + } + } + + return 0 +} + +// CheckForUpdate checks if a newer version is available. +// It first verifies internet connectivity, then fetches the latest version from GitHub. +// Returns UpdateInfo with details about the update, or an error if the check fails. +func CheckForUpdate(currentVersion string) (*UpdateInfo, error) { + info := &UpdateInfo{ + CurrentVersion: currentVersion, + HasUpdate: false, + } + + // Quick internet connectivity check + if !IsConnectedToInternet() { + return nil, fmt.Errorf("no internet connection") + } + + // Fetch latest version from GitHub + latestVersion, err := GetLatestVersion() + if err != nil { + return nil, err + } + + info.LatestVersion = latestVersion + info.UpdateURL = fmt.Sprintf("https://github.com/DylanDevelops/tmpo/releases/tag/%s", latestVersion) + + // Compare versions + comparison := CompareVersions(currentVersion, latestVersion) + if comparison < 0 { + info.HasUpdate = true + } + + return info, nil +} diff --git a/internal/update/checker_test.go b/internal/update/checker_test.go new file mode 100644 index 0000000..3d9db9d --- /dev/null +++ b/internal/update/checker_test.go @@ -0,0 +1,99 @@ +package update + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + expected int + }{ + { + name: "update available - patch version", + current: "1.2.3", + latest: "1.2.4", + expected: -1, + }, + { + name: "update available - minor version", + current: "1.2.3", + latest: "1.3.0", + expected: -1, + }, + { + name: "update available - major version", + current: "1.2.3", + latest: "2.0.0", + expected: -1, + }, + { + name: "same version", + current: "1.2.3", + latest: "1.2.3", + expected: 0, + }, + { + name: "ahead of latest (dev build)", + current: "1.3.0", + latest: "1.2.3", + expected: 1, + }, + { + name: "with v prefix - current", + current: "v1.2.3", + latest: "1.2.4", + expected: -1, + }, + { + name: "with v prefix - latest", + current: "1.2.3", + latest: "v1.2.4", + expected: -1, + }, + { + name: "with v prefix - both", + current: "v1.2.3", + latest: "v1.2.4", + expected: -1, + }, + { + name: "large version numbers", + current: "1.20.3", + latest: "1.21.0", + expected: -1, + }, + { + name: "different lengths - current shorter", + current: "1.2", + latest: "1.2.1", + expected: -1, + }, + { + name: "different lengths - latest shorter", + current: "1.2.1", + latest: "1.2", + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CompareVersions(tt.current, tt.latest) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsConnectedToInternet(t *testing.T) { + // This test will pass if there's internet connectivity + // It's more of an integration test than a unit test + result := IsConnectedToInternet() + // We can't assert true/false since it depends on actual connectivity + // Just verify it returns without panicking + t.Logf("Internet connectivity: %v", result) +}