Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -30,5 +41,5 @@ func Execute() {
}

func init() {
rootCmd.SetVersionTemplate(GetVersionOutput())
rootCmd.Flags().BoolP("version", "v", false, "version for tmpo")
}
34 changes: 29 additions & 5 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/DylanDevelops/tmpo/internal/update"
"github.com/spf13/cobra"
)

Expand All @@ -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 {
Expand All @@ -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 ""
Expand All @@ -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)
}
139 changes: 139 additions & 0 deletions internal/update/checker.go
Original file line number Diff line number Diff line change
@@ -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", &currentVal)
}
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
}
99 changes: 99 additions & 0 deletions internal/update/checker_test.go
Original file line number Diff line number Diff line change
@@ -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)
}