Skip to content

Commit a3bd466

Browse files
committed
feat: sync shell config (theme and plugins) from remote config
openboot sync now detects and applies Oh-My-Zsh theme and plugin differences when the remote config specifies a shell section. - Add snapshot.CaptureShell() to read local .zshrc theme and plugins - Add ShellDiff to SyncDiff with HasChanges/TotalChanged support - Add shell fields to SyncPlan; execute via shell.RestoreFromSnapshot - Show Shell Changes section in sync TUI with confirm prompt - Add VM E2E tests: CaptureShell and no-panic with shell config
1 parent c2f465c commit a3bd466

7 files changed

Lines changed: 237 additions & 2 deletions

File tree

internal/cli/sync.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,26 @@ func printSyncDiff(d *syncpkg.SyncDiff) {
213213
fmt.Println()
214214
}
215215

216+
// Shell changes
217+
if d.Shell != nil {
218+
fmt.Printf(" %s\n", ui.Green("Shell Changes"))
219+
if d.Shell.ThemeChanged {
220+
localTheme := d.Shell.LocalTheme
221+
if localTheme == "" {
222+
localTheme = "(none)"
223+
}
224+
fmt.Printf(" Theme: %s %s %s\n", localTheme, ui.Yellow("→"), d.Shell.RemoteTheme)
225+
}
226+
if d.Shell.PluginsChanged {
227+
localPlugins := strings.Join(d.Shell.LocalPlugins, ", ")
228+
if localPlugins == "" {
229+
localPlugins = "(none)"
230+
}
231+
fmt.Printf(" Plugins: %s %s %s\n", localPlugins, ui.Yellow("→"), strings.Join(d.Shell.RemotePlugins, ", "))
232+
}
233+
fmt.Println()
234+
}
235+
216236
// Dotfiles changes
217237
if d.DotfilesChanged {
218238
fmt.Printf(" %s\n", ui.Green("Dotfiles"))
@@ -294,6 +314,20 @@ func buildSyncPlan(d *syncpkg.SyncDiff, rc *config.RemoteConfig, dryRun bool, in
294314
}
295315
}
296316

317+
// Shell config
318+
if d.Shell != nil {
319+
apply, err := ui.Confirm("Update shell config (theme and plugins)?", true)
320+
if err != nil {
321+
return nil, fmt.Errorf("confirm shell: %w", err)
322+
}
323+
if apply {
324+
plan.UpdateShell = true
325+
plan.ShellOhMyZsh = true
326+
plan.ShellTheme = d.Shell.RemoteTheme
327+
plan.ShellPlugins = d.Shell.RemotePlugins
328+
}
329+
}
330+
297331
// Dotfiles
298332
if d.DotfilesChanged {
299333
apply, err := ui.Confirm(
@@ -318,6 +352,13 @@ func buildDryRunPlan(d *syncpkg.SyncDiff) *syncpkg.SyncPlan {
318352
InstallTaps: d.MissingTaps,
319353
}
320354

355+
if d.Shell != nil {
356+
plan.UpdateShell = true
357+
plan.ShellOhMyZsh = true
358+
plan.ShellTheme = d.Shell.RemoteTheme
359+
plan.ShellPlugins = d.Shell.RemotePlugins
360+
}
361+
321362
if d.DotfilesChanged {
322363
plan.UpdateDotfiles = d.RemoteDotfiles
323364
}

internal/snapshot/capture.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"regexp"
89
"strings"
910
"time"
1011

@@ -387,6 +388,42 @@ func CaptureDotfiles() (*DotfilesSnapshot, error) {
387388
}, nil
388389
}
389390

391+
var (
392+
zshThemeRe = regexp.MustCompile(`(?m)^ZSH_THEME="([^"]*)"`)
393+
zshPluginsRe = regexp.MustCompile(`(?m)^plugins=\((?s:(.*?))\)`)
394+
)
395+
396+
// CaptureShell reads the current Oh-My-Zsh state and .zshrc theme/plugins.
397+
// Returns a zero-value ShellSnapshot (not an error) when .zshrc is absent.
398+
func CaptureShell() (*ShellSnapshot, error) {
399+
snap := &ShellSnapshot{}
400+
401+
home, err := os.UserHomeDir()
402+
if err != nil {
403+
return snap, nil
404+
}
405+
406+
if _, err := os.Stat(filepath.Join(home, ".oh-my-zsh")); err == nil {
407+
snap.OhMyZsh = true
408+
}
409+
410+
raw, err := os.ReadFile(filepath.Join(home, ".zshrc"))
411+
if err != nil {
412+
return snap, nil // no .zshrc is fine
413+
}
414+
content := string(raw)
415+
416+
if m := zshThemeRe.FindStringSubmatch(content); len(m) > 1 {
417+
snap.Theme = m[1]
418+
}
419+
420+
if m := zshPluginsRe.FindStringSubmatch(content); len(m) > 1 {
421+
snap.Plugins = strings.Fields(m[1])
422+
}
423+
424+
return snap, nil
425+
}
426+
390427
func sanitizePath(path string) string {
391428
home, err := os.UserHomeDir()
392429
if err != nil {

internal/snapshot/snapshot.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ type DevTool struct {
153153
Version string `json:"version"`
154154
}
155155

156+
type ShellSnapshot struct {
157+
OhMyZsh bool `json:"oh_my_zsh"`
158+
Theme string `json:"theme"`
159+
Plugins []string `json:"plugins"`
160+
}
161+
156162
type CatalogMatch struct {
157163
Matched []string `json:"matched"`
158164
Unmatched []string `json:"unmatched"`

internal/sync/diff.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ type SyncDiff struct {
3131

3232
// macOS Preferences
3333
MacOSChanged []MacOSPrefDiff
34+
35+
// Shell (non-nil when theme or plugins differ from remote)
36+
Shell *ShellDiff
37+
}
38+
39+
// ShellDiff records a shell config difference between remote and local.
40+
// RemoteTheme/RemotePlugins always reflect the remote config values.
41+
type ShellDiff struct {
42+
ThemeChanged bool
43+
RemoteTheme string
44+
LocalTheme string
45+
PluginsChanged bool
46+
RemotePlugins []string
47+
LocalPlugins []string
3448
}
3549

3650
// MacOSPrefDiff records a single macOS preference that differs.
@@ -54,7 +68,8 @@ func (d *SyncDiff) HasChanges() bool {
5468
len(d.ExtraNpm) > 0 ||
5569
len(d.ExtraTaps) > 0 ||
5670
d.DotfilesChanged ||
57-
len(d.MacOSChanged) > 0
71+
len(d.MacOSChanged) > 0 ||
72+
d.Shell != nil
5873
}
5974

6075
// TotalMissing returns the count of items in remote but not on the local system.
@@ -67,12 +82,15 @@ func (d *SyncDiff) TotalExtra() int {
6782
return len(d.ExtraFormulae) + len(d.ExtraCasks) + len(d.ExtraNpm) + len(d.ExtraTaps)
6883
}
6984

70-
// TotalChanged returns the count of values that differ (theme, dotfiles, macOS prefs).
85+
// TotalChanged returns the count of values that differ (theme, dotfiles, macOS prefs, shell).
7186
func (d *SyncDiff) TotalChanged() int {
7287
n := len(d.MacOSChanged)
7388
if d.DotfilesChanged {
7489
n++
7590
}
91+
if d.Shell != nil {
92+
n++
93+
}
7694
return n
7795
}
7896

@@ -122,6 +140,28 @@ func ComputeDiff(rc *config.RemoteConfig) (*SyncDiff, error) {
122140
}
123141
}
124142

143+
// Shell diff — only when remote config specifies oh-my-zsh
144+
if rc.Shell != nil && rc.Shell.OhMyZsh {
145+
localShell, err := snapshot.CaptureShell()
146+
if err != nil {
147+
return nil, fmt.Errorf("capture local shell: %w", err)
148+
}
149+
var sd *ShellDiff
150+
if rc.Shell.Theme != "" && rc.Shell.Theme != localShell.Theme {
151+
if sd == nil {
152+
sd = &ShellDiff{RemoteTheme: rc.Shell.Theme, LocalTheme: localShell.Theme, RemotePlugins: rc.Shell.Plugins, LocalPlugins: localShell.Plugins}
153+
}
154+
sd.ThemeChanged = true
155+
}
156+
if len(rc.Shell.Plugins) > 0 && !pluginsEqual(rc.Shell.Plugins, localShell.Plugins) {
157+
if sd == nil {
158+
sd = &ShellDiff{RemoteTheme: rc.Shell.Theme, LocalTheme: localShell.Theme, RemotePlugins: rc.Shell.Plugins, LocalPlugins: localShell.Plugins}
159+
}
160+
sd.PluginsChanged = true
161+
}
162+
d.Shell = sd
163+
}
164+
125165
// macOS prefs diff
126166
if len(rc.MacOSPrefs) > 0 {
127167
localPrefs, prefsErr := snapshot.CaptureMacOSPrefs()
@@ -156,6 +196,24 @@ func ComputeDiff(rc *config.RemoteConfig) (*SyncDiff, error) {
156196
return d, nil
157197
}
158198

199+
// pluginsEqual reports whether two plugin lists contain the same elements
200+
// regardless of order.
201+
func pluginsEqual(a, b []string) bool {
202+
if len(a) != len(b) {
203+
return false
204+
}
205+
set := make(map[string]bool, len(a))
206+
for _, p := range a {
207+
set[p] = true
208+
}
209+
for _, p := range b {
210+
if !set[p] {
211+
return false
212+
}
213+
}
214+
return true
215+
}
216+
159217
// diffLists returns (missing, extra) where missing = in remote but not local,
160218
// extra = in local but not remote.
161219
func diffLists(remote, local []string) (missing, extra []string) {

internal/sync/plan.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/openbootdotdev/openboot/internal/dotfiles"
1010
"github.com/openbootdotdev/openboot/internal/macos"
1111
"github.com/openbootdotdev/openboot/internal/npm"
12+
"github.com/openbootdotdev/openboot/internal/shell"
1213
)
1314

1415
// SyncPlan describes the concrete actions to apply after the user selects
@@ -31,6 +32,12 @@ type SyncPlan struct {
3132

3233
// macOS
3334
UpdateMacOSPrefs []config.RemoteMacOSPref
35+
36+
// Shell
37+
UpdateShell bool
38+
ShellOhMyZsh bool
39+
ShellTheme string
40+
ShellPlugins []string
3441
}
3542

3643
// SyncResult summarizes what was applied.
@@ -49,6 +56,9 @@ func (p *SyncPlan) TotalActions() int {
4956
if p.UpdateDotfiles != "" {
5057
n++
5158
}
59+
if p.UpdateShell {
60+
n++
61+
}
5262
return n
5363
}
5464

@@ -156,6 +166,16 @@ func Execute(plan *SyncPlan, dryRun bool) (*SyncResult, error) {
156166
}
157167
}
158168

169+
// Update shell config
170+
if plan.UpdateShell {
171+
if err := shell.RestoreFromSnapshot(plan.ShellOhMyZsh, plan.ShellTheme, plan.ShellPlugins, dryRun); err != nil {
172+
errs = append(errs, fmt.Errorf("update shell: %w", err))
173+
result.Errors = append(result.Errors, fmt.Sprintf("shell: %v", err))
174+
} else {
175+
result.Updated++
176+
}
177+
}
178+
159179
// Apply macOS preferences
160180
if len(plan.UpdateMacOSPrefs) > 0 {
161181
prefs := make([]macos.Preference, len(plan.UpdateMacOSPrefs))

test/e2e/sync_shell_e2e_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//go:build e2e && vm
2+
3+
package e2e
4+
5+
import (
6+
"strings"
7+
"testing"
8+
9+
"github.com/openbootdotdev/openboot/testutil"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestE2E_Sync_Shell_CaptureShell verifies that CaptureShell works correctly
15+
// in a real macOS environment: detects Oh-My-Zsh and reads theme/plugins from .zshrc.
16+
func TestE2E_Sync_Shell_CaptureShell(t *testing.T) {
17+
if testing.Short() {
18+
t.Skip("skipping VM test in short mode")
19+
}
20+
21+
vm := testutil.NewTartVM(t)
22+
installOhMyZsh(t, vm)
23+
bin := vmCopyDevBinary(t, vm)
24+
25+
// Set a known theme and plugins so we can assert on them
26+
_, err := vm.Run(`sed -i '' 's/ZSH_THEME="[^"]*"/ZSH_THEME="agnoster"/' ~/.zshrc`)
27+
require.NoError(t, err)
28+
_, err = vm.Run(`sed -i '' 's/plugins=(git)/plugins=(git docker)/' ~/.zshrc`)
29+
require.NoError(t, err)
30+
31+
zshrc, err := vm.Run("cat ~/.zshrc")
32+
require.NoError(t, err)
33+
assert.Contains(t, zshrc, `ZSH_THEME="agnoster"`, ".zshrc should have agnoster theme")
34+
assert.Contains(t, zshrc, "docker", ".zshrc should have docker plugin")
35+
36+
// Run snapshot --json to exercise the full capture path (including CaptureShell indirectly)
37+
snapshotOut, err := vmRunDevBinary(t, vm, bin, "snapshot --json")
38+
require.NoError(t, err, "snapshot should succeed, output: %s", snapshotOut)
39+
assert.NotEmpty(t, snapshotOut)
40+
41+
_, err = vm.Run("test -d ~/.oh-my-zsh")
42+
assert.NoError(t, err, "~/.oh-my-zsh should exist after install")
43+
}
44+
45+
// TestE2E_Sync_Shell_NoPanic verifies that the binary handles a remote config
46+
// with shell settings without panicking.
47+
func TestE2E_Sync_Shell_NoPanic(t *testing.T) {
48+
if testing.Short() {
49+
t.Skip("skipping VM test in short mode")
50+
}
51+
52+
vm := testutil.NewTartVM(t)
53+
installOhMyZsh(t, vm)
54+
bin := vmCopyDevBinary(t, vm)
55+
56+
cfg := `{"username":"testuser","slug":"default","packages":[],"casks":[],"taps":[],"npm":[],"shell":{"oh_my_zsh":true,"theme":"agnoster","plugins":["git","docker"]}}`
57+
escaped := strings.ReplaceAll(cfg, "'", "'\\''")
58+
_, err := vm.Run("printf '%s' '" + escaped + "' > /tmp/shell-config.json")
59+
require.NoError(t, err)
60+
61+
out, _ := vmRunDevBinaryWithGit(t, vm, bin, "--from /tmp/shell-config.json --silent --dry-run")
62+
t.Logf("dry-run output:\n%s", out)
63+
assert.NotContains(t, out, "panic", "binary should not panic with shell config")
64+
}

test/e2e/vm_helpers_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ func vmRunDevBinaryWithGit(t *testing.T, vm *testutil.TartVM, binaryPath, args s
105105
return vm.RunWithEnv(env, binaryPath+" "+args)
106106
}
107107

108+
// installOhMyZsh installs Oh-My-Zsh non-interactively in the VM.
109+
func installOhMyZsh(t *testing.T, vm *testutil.TartVM) {
110+
t.Helper()
111+
script := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended`
112+
output, err := vm.Run(script)
113+
t.Logf("oh-my-zsh install: %s", output)
114+
require.NoError(t, err, "should install oh-my-zsh")
115+
}
116+
108117
// vmBrewList returns the list of installed Homebrew formulae in the VM.
109118
func vmBrewList(t *testing.T, vm *testutil.TartVM) []string {
110119
t.Helper()

0 commit comments

Comments
 (0)