Skip to content

Commit 468e6a7

Browse files
feat(cli/snapshot): wire macOS pref restore and warn on partial import
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent e1624cb commit 468e6a7

4 files changed

Lines changed: 227 additions & 38 deletions

File tree

internal/cli/snapshot.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,25 @@ func runSnapshotImport(importPath string, dryRun bool) error {
515515
return err
516516
}
517517

518+
if snap.Health.Partial {
519+
fmt.Fprintln(os.Stderr)
520+
ui.Warn(fmt.Sprintf("This snapshot is incomplete — %d capture step(s) failed: %s",
521+
len(snap.Health.FailedSteps),
522+
strings.Join(snap.Health.FailedSteps, ", ")))
523+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render(" Some data may be missing. The restore will proceed with what was captured."))
524+
fmt.Fprintln(os.Stderr)
525+
proceed, err := ui.Confirm("Continue with partial snapshot?", false)
526+
if err != nil {
527+
return err
528+
}
529+
if !proceed {
530+
fmt.Fprintln(os.Stderr)
531+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render("Restore cancelled."))
532+
fmt.Fprintln(os.Stderr)
533+
return nil
534+
}
535+
}
536+
518537
showRestoreInfo(snap, importPath)
519538

520539
edited, confirmed, err := ui.RunSnapshotEditor(snap)
@@ -682,5 +701,15 @@ func buildImportConfig(edited *snapshot.Snapshot, dryRun bool) *config.Config {
682701
Plugins: edited.Shell.Plugins,
683702
}
684703

704+
cfg.SnapshotMacOS = make([]config.SnapshotMacOSPref, len(edited.MacOSPrefs))
705+
for i, p := range edited.MacOSPrefs {
706+
cfg.SnapshotMacOS[i] = config.SnapshotMacOSPref{
707+
Domain: p.Domain,
708+
Key: p.Key,
709+
Value: p.Value,
710+
Desc: p.Desc,
711+
}
712+
}
713+
685714
return cfg
686715
}

internal/snapshot/capture.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"time"
1111

1212
"github.com/openbootdotdev/openboot/internal/macos"
13-
"github.com/openbootdotdev/openboot/internal/system"
1413
)
1514

1615
func Capture() (*Snapshot, error) {
@@ -313,29 +312,6 @@ func CaptureGit() (*GitSnapshot, error) {
313312
return snap, nil
314313
}
315314

316-
// RestoreGit sets git user.name/email if not already configured.
317-
func RestoreGit(git GitSnapshot) error {
318-
existingName, existingEmail := system.GetExistingGitConfig()
319-
320-
if git.UserName == "" && git.UserEmail == "" {
321-
return nil
322-
}
323-
324-
if existingName == "" && git.UserName != "" {
325-
if err := system.RunCommand("git", "config", "--global", "user.name", git.UserName); err != nil {
326-
return fmt.Errorf("failed to restore git user.name: %w", err)
327-
}
328-
}
329-
330-
if existingEmail == "" && git.UserEmail != "" {
331-
if err := system.RunCommand("git", "config", "--global", "user.email", git.UserEmail); err != nil {
332-
return fmt.Errorf("failed to restore git user.email: %w", err)
333-
}
334-
}
335-
336-
return nil
337-
}
338-
339315
var devToolCommands = []struct {
340316
name string
341317
args []string

internal/updater/updater.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"github.com/openbootdotdev/openboot/internal/ui"
1717
)
1818

19-
const checkInterval = 24 * time.Hour
19+
const CheckInterval = 24 * time.Hour
2020

2121
var (
2222
httpClient *http.Client
@@ -45,7 +45,7 @@ type UserConfig struct {
4545
AutoUpdate AutoUpdateMode `json:"autoupdate"`
4646
}
4747

48-
func loadUserConfig() UserConfig {
48+
func LoadUserConfig() UserConfig {
4949
cfg := UserConfig{AutoUpdate: AutoUpdateEnabled}
5050
path, err := getUserConfigPath()
5151
if err != nil {
@@ -100,17 +100,17 @@ func AutoUpgrade(currentVersion string) {
100100
return
101101
}
102102

103-
cfg := loadUserConfig()
103+
cfg := LoadUserConfig()
104104

105105
switch cfg.AutoUpdate {
106106
case AutoUpdateDisabled:
107107
return
108108
case AutoUpdateNotify:
109-
notifyIfUpdateAvailable(currentVersion)
109+
NotifyIfUpdateAvailable(currentVersion)
110110
checkForUpdatesAsync(currentVersion)
111111
return
112112
default:
113-
latest, err := getLatestVersion()
113+
latest, err := GetLatestVersion()
114114
if err != nil {
115115
return
116116
}
@@ -190,8 +190,8 @@ func DownloadAndReplace() error {
190190
return nil
191191
}
192192

193-
func notifyIfUpdateAvailable(currentVersion string) {
194-
state, err := loadState()
193+
func NotifyIfUpdateAvailable(currentVersion string) {
194+
state, err := LoadState()
195195
if err != nil {
196196
return
197197
}
@@ -205,17 +205,17 @@ func notifyIfUpdateAvailable(currentVersion string) {
205205

206206
func checkForUpdatesAsync(currentVersion string) {
207207
go func() {
208-
state, _ := loadState()
209-
if state != nil && time.Since(state.LastCheck) < checkInterval {
208+
state, _ := LoadState()
209+
if state != nil && time.Since(state.LastCheck) < CheckInterval {
210210
return
211211
}
212212

213-
latestVersion, err := getLatestVersion()
213+
latestVersion, err := GetLatestVersion()
214214
if err != nil {
215215
return
216216
}
217217

218-
saveState(&CheckState{
218+
SaveState(&CheckState{
219219
LastCheck: time.Now(),
220220
LatestVersion: latestVersion,
221221
UpdateAvailable: isNewerVersion(latestVersion, currentVersion),
@@ -278,7 +278,7 @@ func getHTTPClient() *http.Client {
278278
return httpClient
279279
}
280280

281-
func getLatestVersion() (string, error) {
281+
func GetLatestVersion() (string, error) {
282282
client := getHTTPClient()
283283
resp, err := client.Get("https://api.github.com/repos/openbootdotdev/openboot/releases/latest")
284284
if err != nil {
@@ -306,7 +306,7 @@ func getCheckFilePath() (string, error) {
306306
return filepath.Join(home, ".openboot", "update_state.json"), nil
307307
}
308308

309-
func loadState() (*CheckState, error) {
309+
func LoadState() (*CheckState, error) {
310310
path, err := getCheckFilePath()
311311
if err != nil {
312312
return nil, err
@@ -325,7 +325,7 @@ func loadState() (*CheckState, error) {
325325
return &state, nil
326326
}
327327

328-
func saveState(state *CheckState) error {
328+
func SaveState(state *CheckState) error {
329329
path, err := getCheckFilePath()
330330
if err != nil {
331331
return err

internal/updater/updater_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package updater
22

33
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
47
"testing"
8+
"time"
59

610
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
712
)
813

914
func TestIsNewerVersion(t *testing.T) {
@@ -127,3 +132,182 @@ func TestTrimVersionPrefix(t *testing.T) {
127132
})
128133
}
129134
}
135+
136+
func TestCompareSemver(t *testing.T) {
137+
tests := []struct {
138+
a, b string
139+
expected int
140+
}{
141+
{"2.0.0", "1.0.0", 1},
142+
{"1.0.0", "2.0.0", -1},
143+
{"1.0.0", "1.0.0", 0},
144+
{"1.2.3", "1.2.2", 1},
145+
{"1.2.2", "1.2.3", -1},
146+
{"1.10.0", "1.9.0", 1},
147+
{"0.0.1", "0.0.0", 1},
148+
}
149+
for _, tt := range tests {
150+
result := compareSemver(tt.a, tt.b)
151+
assert.Equal(t, tt.expected, result, "compareSemver(%q, %q)", tt.a, tt.b)
152+
}
153+
}
154+
155+
func TestParseSemver(t *testing.T) {
156+
tests := []struct {
157+
input string
158+
expected [3]int
159+
}{
160+
{"1.2.3", [3]int{1, 2, 3}},
161+
{"10.20.30", [3]int{10, 20, 30}},
162+
{"1.0.0", [3]int{1, 0, 0}},
163+
{"", [3]int{0, 0, 0}},
164+
{"abc", [3]int{0, 0, 0}},
165+
{"1.abc.3", [3]int{1, 0, 3}},
166+
}
167+
for _, tt := range tests {
168+
result := parseSemver(tt.input)
169+
assert.Equal(t, tt.expected, result, "parseSemver(%q)", tt.input)
170+
}
171+
}
172+
173+
func TestIsNewerVersion_DevBuild(t *testing.T) {
174+
assert.False(t, isNewerVersion("v99.0.0", "dev"), "dev builds should never trigger update")
175+
}
176+
177+
func TestGetHTTPClient_Singleton(t *testing.T) {
178+
c1 := getHTTPClient()
179+
c2 := getHTTPClient()
180+
assert.Same(t, c1, c2, "getHTTPClient should return same instance")
181+
assert.NotNil(t, c1)
182+
}
183+
184+
func TestGetCheckFilePath(t *testing.T) {
185+
t.Setenv("HOME", t.TempDir())
186+
path, err := getCheckFilePath()
187+
require.NoError(t, err)
188+
assert.Contains(t, path, ".openboot")
189+
assert.Contains(t, path, "update_state.json")
190+
}
191+
192+
func TestGetUserConfigPath(t *testing.T) {
193+
t.Setenv("HOME", t.TempDir())
194+
path, err := getUserConfigPath()
195+
require.NoError(t, err)
196+
assert.Contains(t, path, ".openboot")
197+
assert.Contains(t, path, "config.json")
198+
}
199+
200+
func TestLoadState_FileNotFound(t *testing.T) {
201+
t.Setenv("HOME", t.TempDir())
202+
_, err := LoadState()
203+
assert.Error(t, err)
204+
}
205+
206+
func TestSaveState_And_LoadState(t *testing.T) {
207+
t.Setenv("HOME", t.TempDir())
208+
now := time.Now().Truncate(time.Second)
209+
state := &CheckState{
210+
LastCheck: now,
211+
LatestVersion: "v1.2.3",
212+
UpdateAvailable: true,
213+
}
214+
require.NoError(t, SaveState(state))
215+
216+
loaded, err := LoadState()
217+
require.NoError(t, err)
218+
assert.Equal(t, "v1.2.3", loaded.LatestVersion)
219+
assert.True(t, loaded.UpdateAvailable)
220+
assert.Equal(t, now, loaded.LastCheck.Truncate(time.Second))
221+
}
222+
223+
func TestLoadUserConfig_Default_NoFile(t *testing.T) {
224+
t.Setenv("HOME", t.TempDir())
225+
cfg := LoadUserConfig()
226+
assert.Equal(t, AutoUpdateEnabled, cfg.AutoUpdate)
227+
}
228+
229+
func TestLoadUserConfig_FromFile(t *testing.T) {
230+
tmpDir := t.TempDir()
231+
t.Setenv("HOME", tmpDir)
232+
cfgDir := filepath.Join(tmpDir, ".openboot")
233+
require.NoError(t, os.MkdirAll(cfgDir, 0755))
234+
data, err := json.Marshal(UserConfig{AutoUpdate: AutoUpdateNotify})
235+
require.NoError(t, err)
236+
require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.json"), data, 0644))
237+
238+
cfg := LoadUserConfig()
239+
assert.Equal(t, AutoUpdateNotify, cfg.AutoUpdate)
240+
}
241+
242+
func TestLoadUserConfig_InvalidJSON(t *testing.T) {
243+
tmpDir := t.TempDir()
244+
t.Setenv("HOME", tmpDir)
245+
cfgDir := filepath.Join(tmpDir, ".openboot")
246+
require.NoError(t, os.MkdirAll(cfgDir, 0755))
247+
require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte("{bad json"), 0644))
248+
249+
cfg := LoadUserConfig()
250+
assert.Equal(t, AutoUpdateEnabled, cfg.AutoUpdate)
251+
}
252+
253+
func TestLoadUserConfig_EmptyAutoUpdate(t *testing.T) {
254+
tmpDir := t.TempDir()
255+
t.Setenv("HOME", tmpDir)
256+
cfgDir := filepath.Join(tmpDir, ".openboot")
257+
require.NoError(t, os.MkdirAll(cfgDir, 0755))
258+
require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(`{"autoupdate":""}`), 0644))
259+
260+
cfg := LoadUserConfig()
261+
assert.Equal(t, AutoUpdateEnabled, cfg.AutoUpdate)
262+
}
263+
264+
func TestLoadUserConfig_DisabledMode(t *testing.T) {
265+
tmpDir := t.TempDir()
266+
t.Setenv("HOME", tmpDir)
267+
cfgDir := filepath.Join(tmpDir, ".openboot")
268+
require.NoError(t, os.MkdirAll(cfgDir, 0755))
269+
data, err := json.Marshal(UserConfig{AutoUpdate: AutoUpdateDisabled})
270+
require.NoError(t, err)
271+
require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.json"), data, 0644))
272+
273+
cfg := LoadUserConfig()
274+
assert.Equal(t, AutoUpdateDisabled, cfg.AutoUpdate)
275+
}
276+
277+
func TestAutoUpgrade_DisabledByEnv(t *testing.T) {
278+
t.Setenv("OPENBOOT_DISABLE_AUTOUPDATE", "1")
279+
AutoUpgrade("1.0.0")
280+
}
281+
282+
func TestNotifyIfUpdateAvailable_NoStateFile(t *testing.T) {
283+
t.Setenv("HOME", t.TempDir())
284+
NotifyIfUpdateAvailable("1.0.0")
285+
}
286+
287+
func TestNotifyIfUpdateAvailable_UpdateAvailable(t *testing.T) {
288+
tmpDir := t.TempDir()
289+
t.Setenv("HOME", tmpDir)
290+
require.NoError(t, SaveState(&CheckState{
291+
LastCheck: time.Now(),
292+
LatestVersion: "v2.0.0",
293+
UpdateAvailable: true,
294+
}))
295+
NotifyIfUpdateAvailable("1.0.0")
296+
}
297+
298+
func TestNotifyIfUpdateAvailable_NoUpdate(t *testing.T) {
299+
tmpDir := t.TempDir()
300+
t.Setenv("HOME", tmpDir)
301+
require.NoError(t, SaveState(&CheckState{
302+
LastCheck: time.Now(),
303+
LatestVersion: "v1.0.0",
304+
UpdateAvailable: false,
305+
}))
306+
NotifyIfUpdateAvailable("1.0.0")
307+
}
308+
309+
func TestAutoUpdateModeConstants(t *testing.T) {
310+
assert.Equal(t, AutoUpdateMode("true"), AutoUpdateEnabled)
311+
assert.Equal(t, AutoUpdateMode("notify"), AutoUpdateNotify)
312+
assert.Equal(t, AutoUpdateMode("false"), AutoUpdateDisabled)
313+
}

0 commit comments

Comments
 (0)