Skip to content

Commit 6e838fd

Browse files
committed
feat: auto-update on startup with configurable modes
- Default: silently download and replace binary on launch when new version exists - Configurable via ~/.openboot/config.json: autoupdate = true|notify|false - Env var OPENBOOT_DISABLE_AUTOUPDATE=1 to override - Refactor update --self to reuse shared DownloadAndReplace() - Resolve symlinks before binary replacement for robustness - Increase HTTP timeout from 5s to 15s for binary downloads
1 parent c0a8d11 commit 6e838fd

3 files changed

Lines changed: 140 additions & 66 deletions

File tree

internal/cli/root.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
var (
14-
version = "0.18.0"
14+
version = "0.18.1"
1515
cfg = &config.Config{}
1616
)
1717

@@ -64,8 +64,7 @@ shell configuration, and macOS preferences.`,
6464
return nil
6565
},
6666
RunE: func(cmd *cobra.Command, args []string) error {
67-
updater.ShowUpdateNotificationIfAvailable(version)
68-
updater.CheckForUpdatesAsync(cmd.Context(), version)
67+
updater.AutoUpgrade(version)
6968
cfg.Version = version
7069
err := installer.Run(cfg)
7170
if err == installer.ErrUserCancelled {

internal/cli/update.go

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@ package cli
22

33
import (
44
"fmt"
5-
"io"
6-
"net/http"
7-
"os"
8-
"runtime"
95

106
"github.com/openbootdotdev/openboot/internal/brew"
117
"github.com/openbootdotdev/openboot/internal/ui"
8+
"github.com/openbootdotdev/openboot/internal/updater"
129
"github.com/spf13/cobra"
1310
)
1411

@@ -42,49 +39,9 @@ func runSelfUpdate() error {
4239
ui.Header("OpenBoot Self-Update")
4340
fmt.Println()
4441

45-
arch := runtime.GOARCH
46-
if arch == "" {
47-
arch = "arm64"
48-
}
49-
50-
url := fmt.Sprintf("https://github.com/openbootdotdev/openboot/releases/latest/download/openboot-darwin-%s", arch)
51-
ui.Info(fmt.Sprintf("Downloading latest release (%s)...", arch))
52-
53-
resp, err := http.Get(url)
54-
if err != nil {
55-
return fmt.Errorf("failed to download: %w", err)
56-
}
57-
defer resp.Body.Close()
58-
59-
if resp.StatusCode != http.StatusOK {
60-
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
61-
}
62-
63-
binPath, err := os.Executable()
64-
if err != nil {
65-
return fmt.Errorf("cannot determine binary path: %w", err)
66-
}
67-
68-
tmpPath := binPath + ".tmp"
69-
f, err := os.Create(tmpPath)
70-
if err != nil {
71-
return fmt.Errorf("failed to create temp file: %w", err)
72-
}
73-
defer f.Close()
74-
75-
if _, err := io.Copy(f, resp.Body); err != nil {
76-
os.Remove(tmpPath)
77-
return fmt.Errorf("failed to write binary: %w", err)
78-
}
79-
80-
if err := os.Chmod(tmpPath, 0755); err != nil {
81-
os.Remove(tmpPath)
82-
return fmt.Errorf("failed to set permissions: %w", err)
83-
}
84-
85-
if err := os.Rename(tmpPath, binPath); err != nil {
86-
os.Remove(tmpPath)
87-
return fmt.Errorf("failed to replace binary: %w", err)
42+
ui.Info("Downloading latest release...")
43+
if err := updater.DownloadAndReplace(); err != nil {
44+
return err
8845
}
8946

9047
fmt.Println()

internal/updater/updater.go

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package updater
22

33
import (
4-
"context"
54
"encoding/json"
65
"fmt"
6+
"io"
77
"net/http"
88
"os"
99
"path/filepath"
10+
"runtime"
1011
"sync"
1112
"time"
1213

@@ -30,7 +31,135 @@ type CheckState struct {
3031
UpdateAvailable bool `json:"update_available"`
3132
}
3233

33-
func ShowUpdateNotificationIfAvailable(currentVersion string) {
34+
type AutoUpdateMode string
35+
36+
const (
37+
AutoUpdateEnabled AutoUpdateMode = "true"
38+
AutoUpdateNotify AutoUpdateMode = "notify"
39+
AutoUpdateDisabled AutoUpdateMode = "false"
40+
)
41+
42+
type UserConfig struct {
43+
AutoUpdate AutoUpdateMode `json:"autoupdate"`
44+
}
45+
46+
func loadUserConfig() UserConfig {
47+
cfg := UserConfig{AutoUpdate: AutoUpdateEnabled}
48+
path, err := getUserConfigPath()
49+
if err != nil {
50+
return cfg
51+
}
52+
data, err := os.ReadFile(path)
53+
if err != nil {
54+
return cfg
55+
}
56+
json.Unmarshal(data, &cfg)
57+
if cfg.AutoUpdate == "" {
58+
cfg.AutoUpdate = AutoUpdateEnabled
59+
}
60+
return cfg
61+
}
62+
63+
func getUserConfigPath() (string, error) {
64+
home, err := os.UserHomeDir()
65+
if err != nil {
66+
return "", err
67+
}
68+
return filepath.Join(home, ".openboot", "config.json"), nil
69+
}
70+
71+
func AutoUpgrade(currentVersion string) {
72+
if os.Getenv("OPENBOOT_DISABLE_AUTOUPDATE") == "1" {
73+
return
74+
}
75+
76+
cfg := loadUserConfig()
77+
78+
switch cfg.AutoUpdate {
79+
case AutoUpdateDisabled:
80+
return
81+
case AutoUpdateNotify:
82+
notifyIfUpdateAvailable(currentVersion)
83+
checkForUpdatesAsync(currentVersion)
84+
return
85+
default:
86+
latest, err := getLatestVersion()
87+
if err != nil {
88+
return
89+
}
90+
if !isNewerVersion(latest, currentVersion) {
91+
return
92+
}
93+
94+
latestClean := trimVersionPrefix(latest)
95+
ui.Info(fmt.Sprintf("Updating OpenBoot v%s → v%s...", currentVersion, latestClean))
96+
if err := DownloadAndReplace(); err != nil {
97+
ui.Warn(fmt.Sprintf("Auto-update failed: %v", err))
98+
ui.Muted("Run 'openboot update --self' to update manually")
99+
fmt.Println()
100+
return
101+
}
102+
ui.Success(fmt.Sprintf("Updated to v%s. Restart openboot to use the new version.", latestClean))
103+
fmt.Println()
104+
}
105+
}
106+
107+
func DownloadAndReplace() error {
108+
arch := runtime.GOARCH
109+
if arch == "" {
110+
arch = "arm64"
111+
}
112+
113+
url := fmt.Sprintf("https://github.com/openbootdotdev/openboot/releases/latest/download/openboot-darwin-%s", arch)
114+
115+
client := getHTTPClient()
116+
resp, err := client.Get(url)
117+
if err != nil {
118+
return fmt.Errorf("download failed: %w", err)
119+
}
120+
defer resp.Body.Close()
121+
122+
if resp.StatusCode != http.StatusOK {
123+
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
124+
}
125+
126+
binPath, err := os.Executable()
127+
if err != nil {
128+
return fmt.Errorf("cannot determine binary path: %w", err)
129+
}
130+
131+
binPath, err = filepath.EvalSymlinks(binPath)
132+
if err != nil {
133+
return fmt.Errorf("cannot resolve binary path: %w", err)
134+
}
135+
136+
tmpPath := binPath + ".tmp"
137+
f, err := os.Create(tmpPath)
138+
if err != nil {
139+
return fmt.Errorf("failed to create temp file: %w", err)
140+
}
141+
142+
if _, err := io.Copy(f, resp.Body); err != nil {
143+
f.Close()
144+
os.Remove(tmpPath)
145+
return fmt.Errorf("failed to write binary: %w", err)
146+
}
147+
f.Close()
148+
149+
if err := os.Chmod(tmpPath, 0755); err != nil {
150+
os.Remove(tmpPath)
151+
return fmt.Errorf("failed to set permissions: %w", err)
152+
}
153+
154+
if err := os.Rename(tmpPath, binPath); err != nil {
155+
os.Remove(tmpPath)
156+
return fmt.Errorf("failed to replace binary: %w", err)
157+
}
158+
159+
return nil
160+
}
161+
162+
func notifyIfUpdateAvailable(currentVersion string) {
34163
state, err := loadState()
35164
if err != nil {
36165
return
@@ -43,16 +172,9 @@ func ShowUpdateNotificationIfAvailable(currentVersion string) {
43172
}
44173
}
45174

46-
func CheckForUpdatesAsync(ctx context.Context, currentVersion string) {
175+
func checkForUpdatesAsync(currentVersion string) {
47176
go func() {
48-
select {
49-
case <-ctx.Done():
50-
return
51-
default:
52-
}
53-
54177
state, _ := loadState()
55-
56178
if state != nil && time.Since(state.LastCheck) < checkInterval {
57179
return
58180
}
@@ -62,12 +184,10 @@ func CheckForUpdatesAsync(ctx context.Context, currentVersion string) {
62184
return
63185
}
64186

65-
updateAvailable := isNewerVersion(latestVersion, currentVersion)
66-
67187
saveState(&CheckState{
68188
LastCheck: time.Now(),
69189
LatestVersion: latestVersion,
70-
UpdateAvailable: updateAvailable,
190+
UpdateAvailable: isNewerVersion(latestVersion, currentVersion),
71191
})
72192
}()
73193
}
@@ -76,10 +196,8 @@ func isNewerVersion(latest, current string) bool {
76196
if latest == "" {
77197
return false
78198
}
79-
80199
latestClean := trimVersionPrefix(latest)
81200
currentClean := trimVersionPrefix(current)
82-
83201
return latestClean != currentClean && latestClean > currentClean
84202
}
85203

@@ -92,7 +210,7 @@ func trimVersionPrefix(v string) string {
92210

93211
func getHTTPClient() *http.Client {
94212
httpClientOnce.Do(func() {
95-
httpClient = &http.Client{Timeout: 5 * time.Second}
213+
httpClient = &http.Client{Timeout: 15 * time.Second}
96214
})
97215
return httpClient
98216
}

0 commit comments

Comments
 (0)