Skip to content

Commit cd545d0

Browse files
committed
feat: shell diff in openboot diff, sync --yes, sync logged-in fallback
openboot diff: - Now detects shell config differences (ZSH_THEME, plugins) when the remote config specifies oh_my_zsh: true, matching sync behavior - ShellDiff added to DiffResult, printed in terminal and JSON output - TotalChanged and HasChanges account for shell diff openboot sync: - Falls back to the logged-in user's openboot.dev config when no sync source is saved (avoids "run openboot install first" error for users who are logged in but haven't installed yet) - --yes / -y flag auto-confirms all prompts for non-interactive use (scripts, CI/CD, dotfiles automation); behaves like dry-run plan but actually executes the changes
1 parent b1ee723 commit cd545d0

File tree

4 files changed

+122
-8
lines changed

4 files changed

+122
-8
lines changed

internal/cli/sync.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ var syncCmd = &cobra.Command{
2020
Long: `Fetch the latest remote config and apply changes to your local system.
2121
2222
The sync source is automatically saved when you run 'openboot install <config>'.
23+
If no source is saved, falls back to your logged-in openboot.dev config.
2324
You can override it with --source.
2425
2526
This command compares your local system against the remote config and lets you
2627
interactively select which changes to apply.`,
27-
Example: ` # Sync with the config you last installed
28+
Example: ` # Sync with the config you last installed (or your logged-in config)
2829
openboot sync
2930
3031
# Preview changes without applying
3132
openboot sync --dry-run
3233
34+
# Apply all changes without prompts (for scripts/CI)
35+
openboot sync --yes
36+
3337
# Sync with a different config
3438
openboot sync --source alice/my-setup
3539
@@ -45,12 +49,14 @@ func init() {
4549
syncCmd.Flags().String("source", "", "override remote config source (alias or username/slug)")
4650
syncCmd.Flags().Bool("dry-run", false, "preview changes without applying")
4751
syncCmd.Flags().Bool("install-only", false, "only install missing packages, skip removal prompts")
52+
syncCmd.Flags().BoolP("yes", "y", false, "auto-confirm all prompts (non-interactive)")
4853
}
4954

5055
func runSync(cmd *cobra.Command) error {
5156
sourceOverride, _ := cmd.Flags().GetString("source")
5257
dryRun, _ := cmd.Flags().GetBool("dry-run")
5358
installOnly, _ := cmd.Flags().GetBool("install-only")
59+
yes, _ := cmd.Flags().GetBool("yes")
5460

5561
// 1. Load sync source
5662
var source *syncpkg.SyncSource
@@ -63,7 +69,12 @@ func runSync(cmd *cobra.Command) error {
6369
return fmt.Errorf("load sync source: %w", err)
6470
}
6571
if source == nil {
66-
return fmt.Errorf("no sync source found — run 'openboot install <config>' first, or use --source")
72+
// Fall back to the logged-in user's config
73+
if stored, authErr := auth.LoadToken(); authErr == nil && stored != nil && stored.Username != "" {
74+
source = &syncpkg.SyncSource{UserSlug: stored.Username}
75+
} else {
76+
return fmt.Errorf("no sync source found — run 'openboot install <config>' first, or use --source")
77+
}
6778
}
6879
}
6980

@@ -111,7 +122,7 @@ func runSync(cmd *cobra.Command) error {
111122
printSyncDiff(diff)
112123

113124
// 7. Build plan from interactive selection
114-
plan, err := buildSyncPlan(diff, rc, dryRun, installOnly)
125+
plan, err := buildSyncPlan(diff, rc, dryRun, installOnly, yes)
115126
if err != nil {
116127
return err
117128
}
@@ -121,8 +132,8 @@ func runSync(cmd *cobra.Command) error {
121132
return nil
122133
}
123134

124-
// 8. Confirm (skip in dry-run mode)
125-
if !dryRun {
135+
// 8. Confirm (skip in dry-run and --yes modes)
136+
if !dryRun && !yes {
126137
confirmed, err := ui.Confirm(fmt.Sprintf("Apply %d changes?", plan.TotalActions()), true)
127138
if err != nil {
128139
return fmt.Errorf("confirm: %w", err)
@@ -253,9 +264,9 @@ func printMissingExtra(category string, missing, extra []string) {
253264
}
254265
}
255266

256-
func buildSyncPlan(d *syncpkg.SyncDiff, rc *config.RemoteConfig, dryRun bool, installOnly bool) (*syncpkg.SyncPlan, error) {
257-
// Dry-run: include all missing items without interactive prompts
258-
if dryRun {
267+
func buildSyncPlan(d *syncpkg.SyncDiff, rc *config.RemoteConfig, dryRun bool, installOnly bool, yes bool) (*syncpkg.SyncPlan, error) {
268+
// Dry-run or --yes: include all missing items without interactive prompts
269+
if dryRun || yes {
259270
return buildDryRunPlan(d), nil
260271
}
261272

internal/diff/compare.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,65 @@ func CompareSnapshotToRemote(system *snapshot.Snapshot, remote *config.RemoteCon
6262
result.MacOS = diffMacOS(system.MacOSPrefs, refPrefs)
6363
}
6464

65+
// Shell configuration comparison — only when remote specifies oh-my-zsh
66+
if remote.Shell != nil && remote.Shell.OhMyZsh {
67+
result.Shell = diffShell(remote.Shell.Theme, remote.Shell.Plugins)
68+
}
69+
6570
return result
6671
}
6772

73+
// diffShell captures the local shell state and compares it against reference values.
74+
func diffShell(refTheme string, refPlugins []string) *ShellDiff {
75+
local, err := snapshot.CaptureShell()
76+
if err != nil || local == nil {
77+
return nil
78+
}
79+
80+
var sd *ShellDiff
81+
82+
if refTheme != "" && refTheme != local.Theme {
83+
sd = &ShellDiff{
84+
ThemeChanged: true,
85+
LocalTheme: local.Theme,
86+
ReferenceTheme: refTheme,
87+
LocalPlugins: local.Plugins,
88+
ReferencePlugins: refPlugins,
89+
}
90+
}
91+
92+
if len(refPlugins) > 0 && !pluginsEqual(refPlugins, local.Plugins) {
93+
if sd == nil {
94+
sd = &ShellDiff{
95+
LocalTheme: local.Theme,
96+
ReferenceTheme: refTheme,
97+
LocalPlugins: local.Plugins,
98+
ReferencePlugins: refPlugins,
99+
}
100+
}
101+
sd.PluginsChanged = true
102+
}
103+
104+
return sd
105+
}
106+
107+
// pluginsEqual reports whether two plugin lists contain the same elements regardless of order.
108+
func pluginsEqual(a, b []string) bool {
109+
if len(a) != len(b) {
110+
return false
111+
}
112+
set := make(map[string]bool, len(a))
113+
for _, p := range a {
114+
set[p] = true
115+
}
116+
for _, p := range b {
117+
if !set[p] {
118+
return false
119+
}
120+
}
121+
return true
122+
}
123+
68124
func diffPackages(system, reference *snapshot.Snapshot) PackageDiff {
69125
return PackageDiff{
70126
Formulae: DiffLists(system.Packages.Formulae, reference.Packages.Formulae),

internal/diff/diff.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,24 @@ type DotfilesDiff struct {
7373
Unpushed bool // local commits not pushed to remote
7474
}
7575

76+
// ShellDiff holds shell configuration differences between system and reference.
77+
type ShellDiff struct {
78+
ThemeChanged bool
79+
LocalTheme string
80+
ReferenceTheme string
81+
PluginsChanged bool
82+
LocalPlugins []string
83+
ReferencePlugins []string
84+
}
85+
7686
// DiffResult is the top-level diff output.
7787
type DiffResult struct {
7888
Source Source
7989
Packages PackageDiff
8090
MacOS *MacOSDiff // nil when not compared
8191
DevTools *DevToolDiff // nil when not compared
8292
Dotfiles *DotfilesDiff // nil when not compared
93+
Shell *ShellDiff // nil when not compared
8394
}
8495

8596
// DiffLists computes a bidirectional set diff between system and reference string slices.
@@ -124,6 +135,9 @@ func (r *DiffResult) HasChanges() bool {
124135
if r.Dotfiles != nil && (r.Dotfiles.Dirty || r.Dotfiles.Unpushed || r.Dotfiles.RepoChanged != nil) {
125136
return true
126137
}
138+
if r.Shell != nil {
139+
return true
140+
}
127141
return false
128142
}
129143

@@ -171,6 +185,9 @@ func (r *DiffResult) TotalChanged() int {
171185
if r.Dotfiles != nil && r.Dotfiles.RepoChanged != nil {
172186
n++
173187
}
188+
if r.Shell != nil {
189+
n++
190+
}
174191
return n
175192
}
176193

internal/diff/format.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package diff
33
import (
44
"encoding/json"
55
"fmt"
6+
"strings"
67

78
"github.com/openbootdotdev/openboot/internal/ui"
89
)
@@ -32,6 +33,9 @@ func FormatTerminal(result *DiffResult, packagesOnly bool) {
3233
if result.DevTools != nil {
3334
printDevToolsSection(result.DevTools)
3435
}
36+
if result.Shell != nil {
37+
printShellSection(result.Shell)
38+
}
3539
}
3640

3741
printSummary(result)
@@ -45,6 +49,7 @@ func FormatJSON(result *DiffResult) ([]byte, error) {
4549
Dotfiles: result.Dotfiles,
4650
MacOS: result.MacOS,
4751
DevTools: result.DevTools,
52+
Shell: result.Shell,
4853
Summary: jsonSummary{
4954
Missing: result.TotalMissing(),
5055
Extra: result.TotalExtra(),
@@ -61,6 +66,7 @@ type jsonOutput struct {
6166
Dotfiles *DotfilesDiff `json:"dotfiles,omitempty"`
6267
MacOS *MacOSDiff `json:"macos,omitempty"`
6368
DevTools *DevToolDiff `json:"dev_tools,omitempty"`
69+
Shell *ShellDiff `json:"shell,omitempty"`
6470
Summary jsonSummary `json:"summary"`
6571
}
6672

@@ -163,6 +169,30 @@ func printDevToolsSection(dd *DevToolDiff) {
163169
fmt.Println()
164170
}
165171

172+
func printShellSection(sd *ShellDiff) {
173+
if !sd.ThemeChanged && !sd.PluginsChanged {
174+
return
175+
}
176+
fmt.Printf(" Shell:\n")
177+
if sd.ThemeChanged {
178+
local := sd.LocalTheme
179+
if local == "" {
180+
local = "(none)"
181+
}
182+
fmt.Printf(" %s theme: %s %s %s\n",
183+
ui.Yellow("~"), local, ui.Yellow("\u2192"), sd.ReferenceTheme)
184+
}
185+
if sd.PluginsChanged {
186+
local := strings.Join(sd.LocalPlugins, ", ")
187+
if local == "" {
188+
local = "(none)"
189+
}
190+
fmt.Printf(" %s plugins: %s %s %s\n",
191+
ui.Yellow("~"), local, ui.Yellow("\u2192"), strings.Join(sd.ReferencePlugins, ", "))
192+
}
193+
fmt.Println()
194+
}
195+
166196
func printSummary(result *DiffResult) {
167197
missing := result.TotalMissing()
168198
extra := result.TotalExtra()

0 commit comments

Comments
 (0)