Skip to content

Commit 560bb31

Browse files
committed
feat: add automatic update notification system
- Implement background version checking with 24-hour caching (follows gh CLI pattern) - Check GitHub releases API for latest version asynchronously - Save check state to ~/.openboot/update_state.json - Show update notification on subsequent runs if newer version available - Properly handle version comparison with 'v' prefix normalization - Only check on main installation command to avoid blocking fast commands Based on research of go-github-selfupdate and gh CLI best practices: - Non-blocking background checks - State persistence between runs - Respects GitHub API rate limits with daily caching - Clean separation between check and notification phases
1 parent 92c8d19 commit 560bb31

3 files changed

Lines changed: 136 additions & 2 deletions

File tree

internal/cli/root.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import (
66

77
"github.com/openbootdotdev/openboot/internal/config"
88
"github.com/openbootdotdev/openboot/internal/installer"
9+
"github.com/openbootdotdev/openboot/internal/updater"
910
"github.com/spf13/cobra"
1011
)
1112

1213
var (
13-
version = "0.14.2"
14+
version = "0.14.3"
1415
cfg = &config.Config{}
1516
)
1617

@@ -61,9 +62,12 @@ Self-Update:
6162
cfg.Preset = rc.Preset
6263
}
6364
}
65+
6466
return nil
6567
},
6668
RunE: func(cmd *cobra.Command, args []string) error {
69+
updater.ShowUpdateNotificationIfAvailable(version)
70+
updater.CheckForUpdatesAsync(version)
6771
err := installer.Run(cfg)
6872
if err == installer.ErrUserCancelled {
6973
return nil

internal/installer/installer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func Run(cfg *config.Config) error {
3131

3232
func runInstall(cfg *config.Config) error {
3333
fmt.Println()
34-
ui.Header("OpenBoot Installer v0.14.2")
34+
ui.Header("OpenBoot Installer v0.14.3")
3535
fmt.Println()
3636

3737
if cfg.DryRun {

internal/updater/updater.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package updater
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
"github.com/openbootdotdev/openboot/internal/ui"
12+
)
13+
14+
const checkInterval = 24 * time.Hour
15+
16+
type Release struct {
17+
TagName string `json:"tag_name"`
18+
}
19+
20+
type CheckState struct {
21+
LastCheck time.Time `json:"last_check"`
22+
LatestVersion string `json:"latest_version"`
23+
UpdateAvailable bool `json:"update_available"`
24+
}
25+
26+
func ShowUpdateNotificationIfAvailable(currentVersion string) {
27+
state, err := loadState()
28+
if err != nil {
29+
return
30+
}
31+
32+
if state.UpdateAvailable && isNewerVersion(state.LatestVersion, currentVersion) {
33+
ui.Warn(fmt.Sprintf("New version available: %s (current: v%s)", state.LatestVersion, currentVersion))
34+
ui.Muted("Run 'openboot update --self' to upgrade")
35+
fmt.Println()
36+
}
37+
}
38+
39+
func CheckForUpdatesAsync(currentVersion string) {
40+
go func() {
41+
state, _ := loadState()
42+
43+
if state != nil && time.Since(state.LastCheck) < checkInterval {
44+
return
45+
}
46+
47+
latestVersion, err := getLatestVersion()
48+
if err != nil {
49+
return
50+
}
51+
52+
updateAvailable := isNewerVersion(latestVersion, currentVersion)
53+
54+
saveState(&CheckState{
55+
LastCheck: time.Now(),
56+
LatestVersion: latestVersion,
57+
UpdateAvailable: updateAvailable,
58+
})
59+
}()
60+
}
61+
62+
func isNewerVersion(latest, current string) bool {
63+
if latest == "" {
64+
return false
65+
}
66+
67+
latestClean := trimVersionPrefix(latest)
68+
currentClean := trimVersionPrefix(current)
69+
70+
return latestClean != currentClean && latestClean > currentClean
71+
}
72+
73+
func trimVersionPrefix(v string) string {
74+
if len(v) > 0 && v[0] == 'v' {
75+
return v[1:]
76+
}
77+
return v
78+
}
79+
80+
func getLatestVersion() (string, error) {
81+
client := &http.Client{Timeout: 5 * time.Second}
82+
resp, err := client.Get("https://api.github.com/repos/openbootdotdev/openboot/releases/latest")
83+
if err != nil {
84+
return "", err
85+
}
86+
defer resp.Body.Close()
87+
88+
if resp.StatusCode != http.StatusOK {
89+
return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
90+
}
91+
92+
var release Release
93+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
94+
return "", err
95+
}
96+
97+
return release.TagName, nil
98+
}
99+
100+
func getCheckFilePath() string {
101+
home, _ := os.UserHomeDir()
102+
return filepath.Join(home, ".openboot", "update_state.json")
103+
}
104+
105+
func loadState() (*CheckState, error) {
106+
data, err := os.ReadFile(getCheckFilePath())
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
var state CheckState
112+
if err := json.Unmarshal(data, &state); err != nil {
113+
return nil, err
114+
}
115+
116+
return &state, nil
117+
}
118+
119+
func saveState(state *CheckState) error {
120+
path := getCheckFilePath()
121+
dir := filepath.Dir(path)
122+
os.MkdirAll(dir, 0755)
123+
124+
data, err := json.MarshalIndent(state, "", " ")
125+
if err != nil {
126+
return err
127+
}
128+
129+
return os.WriteFile(path, data, 0644)
130+
}

0 commit comments

Comments
 (0)