update.go 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. package update
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "regexp"
  9. "strings"
  10. "time"
  11. )
  12. const (
  13. githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
  14. userAgent = "crush/1.0"
  15. )
  16. // Default is the default [Client].
  17. var Default Client = &github{}
  18. // Info contains information about an available update.
  19. type Info struct {
  20. Current string
  21. Latest string
  22. URL string
  23. }
  24. // Matches a version string like:
  25. // v0.0.0-0.20251231235959-06c807842604
  26. var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
  27. func (i Info) IsDevelopment() bool {
  28. return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
  29. }
  30. // Available returns true if there's an update available.
  31. //
  32. // If both current and latest are stable versions, returns true if versions are
  33. // different.
  34. // If current is a pre-release and latest isn't, returns true.
  35. // If latest is a pre-release and current isn't, returns false.
  36. func (i Info) Available() bool {
  37. cpr := strings.Contains(i.Current, "-")
  38. lpr := strings.Contains(i.Latest, "-")
  39. // current is pre release && latest isn't a prerelease
  40. if cpr && !lpr {
  41. return true
  42. }
  43. // latest is pre release && current isn't a prerelease
  44. if lpr && !cpr {
  45. return false
  46. }
  47. return i.Current != i.Latest
  48. }
  49. // Check checks if a new version is available.
  50. func Check(ctx context.Context, current string, client Client) (Info, error) {
  51. info := Info{
  52. Current: current,
  53. Latest: current,
  54. }
  55. release, err := client.Latest(ctx)
  56. if err != nil {
  57. return info, fmt.Errorf("failed to fetch latest release: %w", err)
  58. }
  59. info.Latest = strings.TrimPrefix(release.TagName, "v")
  60. info.Current = strings.TrimPrefix(info.Current, "v")
  61. info.URL = release.HTMLURL
  62. return info, nil
  63. }
  64. // Release represents a GitHub release.
  65. type Release struct {
  66. TagName string `json:"tag_name"`
  67. HTMLURL string `json:"html_url"`
  68. }
  69. // Client is a client that can get the latest release.
  70. type Client interface {
  71. Latest(ctx context.Context) (*Release, error)
  72. }
  73. type github struct{}
  74. // Latest implements [Client].
  75. func (c *github) Latest(ctx context.Context) (*Release, error) {
  76. client := &http.Client{
  77. Timeout: 30 * time.Second,
  78. }
  79. req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
  80. if err != nil {
  81. return nil, err
  82. }
  83. req.Header.Set("User-Agent", userAgent)
  84. req.Header.Set("Accept", "application/vnd.github.v3+json")
  85. resp, err := client.Do(req)
  86. if err != nil {
  87. return nil, err
  88. }
  89. defer resp.Body.Close()
  90. if resp.StatusCode != http.StatusOK {
  91. body, _ := io.ReadAll(resp.Body)
  92. return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
  93. }
  94. var release Release
  95. if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
  96. return nil, err
  97. }
  98. return &release, nil
  99. }