diff --git a/internal/archtest/baseline/fmtprint.txt b/internal/archtest/baseline/fmtprint.txt new file mode 100644 index 0000000..4e3f9c4 --- /dev/null +++ b/internal/archtest/baseline/fmtprint.txt @@ -0,0 +1,180 @@ +# Baseline for archtest rule "fmtprint". +# Each line is : of a known existing violation. +# Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... +internal/brew/brew.go:128 +internal/brew/brew.go:157 +internal/brew/brew.go:186 +internal/brew/brew_install.go:36 +internal/brew/brew_install.go:58 +internal/brew/brew_install.go:81 +internal/brew/brew_install.go:104 +internal/brew/brew_install.go:107 +internal/brew/brew_install.go:142 +internal/brew/brew_install.go:222 +internal/brew/brew_install.go:232 +internal/brew/brew_install.go:239 +internal/brew/brew_install.go:271 +internal/brew/brew_install.go:275 +internal/brew/brew_install.go:277 +internal/cli/install.go:368 +internal/cli/install.go:372 +internal/cli/install.go:393 +internal/cli/install.go:405 +internal/cli/install.go:477 +internal/cli/install.go:484 +internal/cli/root.go:86 +internal/cli/snapshot.go:256 +internal/cli/sync_helpers.go:45 +internal/cli/sync_helpers.go:50 +internal/cli/sync_helpers.go:54 +internal/cli/sync_helpers.go:60 +internal/cli/sync_helpers.go:62 +internal/cli/sync_helpers.go:66 +internal/cli/sync_helpers.go:72 +internal/cli/sync_helpers.go:79 +internal/cli/sync_helpers.go:81 +internal/cli/sync_helpers.go:85 +internal/cli/sync_helpers.go:86 +internal/cli/sync_helpers.go:87 +internal/cli/sync_helpers.go:95 +internal/cli/update.go:104 +internal/diff/format.go:14 +internal/diff/format.go:16 +internal/diff/format.go:19 +internal/diff/format.go:97 +internal/diff/format.go:99 +internal/diff/format.go:102 +internal/diff/format.go:105 +internal/diff/format.go:107 +internal/diff/format.go:116 +internal/diff/format.go:118 +internal/diff/format.go:122 +internal/diff/format.go:125 +internal/diff/format.go:127 +internal/diff/format.go:136 +internal/diff/format.go:138 +internal/diff/format.go:142 +internal/diff/format.go:146 +internal/diff/format.go:149 +internal/diff/format.go:158 +internal/diff/format.go:160 +internal/diff/format.go:164 +internal/diff/format.go:167 +internal/diff/format.go:169 +internal/diff/format.go:176 +internal/diff/format.go:182 +internal/diff/format.go:190 +internal/diff/format.go:193 +internal/diff/format.go:204 +internal/diff/format.go:209 +internal/dotfiles/dotfiles.go:62 +internal/dotfiles/dotfiles.go:79 +internal/dotfiles/dotfiles.go:106 +internal/dotfiles/dotfiles.go:116 +internal/dotfiles/dotfiles.go:127 +internal/dotfiles/dotfiles.go:130 +internal/dotfiles/dotfiles.go:203 +internal/dotfiles/dotfiles.go:207 +internal/dotfiles/dotfiles.go:222 +internal/dotfiles/dotfiles.go:271 +internal/dotfiles/dotfiles.go:280 +internal/dotfiles/dotfiles.go:361 +internal/dotfiles/dotfiles.go:425 +internal/dotfiles/dotfiles.go:437 +internal/dotfiles/dotfiles.go:440 +internal/dotfiles/dotfiles.go:444 +internal/dotfiles/dotfiles.go:446 +internal/installer/installer.go:48 +internal/installer/installer.go:50 +internal/installer/installer.go:54 +internal/installer/installer.go:128 +internal/installer/installer.go:134 +internal/installer/installer.go:140 +internal/installer/installer.go:151 +internal/installer/installer.go:158 +internal/installer/installer.go:183 +internal/installer/installer.go:192 +internal/installer/installer.go:204 +internal/installer/installer.go:214 +internal/installer/installer.go:216 +internal/installer/installer.go:220 +internal/installer/installer.go:231 +internal/installer/installer.go:241 +internal/installer/installer.go:302 +internal/installer/installer.go:304 +internal/installer/installer.go:307 +internal/installer/installer.go:337 +internal/installer/step_git.go:11 +internal/installer/step_git.go:16 +internal/installer/step_git.go:22 +internal/installer/step_git.go:27 +internal/installer/step_git.go:35 +internal/installer/step_packages.go:76 +internal/installer/step_packages.go:82 +internal/installer/step_packages.go:133 +internal/installer/step_packages.go:138 +internal/installer/step_packages.go:158 +internal/installer/step_packages.go:204 +internal/installer/step_packages.go:206 +internal/installer/step_packages.go:208 +internal/installer/step_packages.go:226 +internal/installer/step_packages.go:227 +internal/installer/step_shell.go:20 +internal/installer/step_shell.go:43 +internal/installer/step_shell.go:61 +internal/installer/step_shell.go:92 +internal/installer/step_shell.go:117 +internal/installer/step_shell.go:158 +internal/installer/step_shell.go:168 +internal/installer/step_shell.go:223 +internal/installer/step_system.go:21 +internal/installer/step_system.go:25 +internal/installer/step_system.go:39 +internal/installer/step_system.go:49 +internal/installer/step_system.go:53 +internal/installer/step_system.go:59 +internal/installer/step_system.go:61 +internal/installer/step_system.go:70 +internal/installer/step_system.go:76 +internal/installer/step_system.go:77 +internal/installer/step_system.go:91 +internal/installer/step_system.go:95 +internal/installer/step_system.go:105 +internal/installer/step_system.go:121 +internal/installer/step_system.go:127 +internal/installer/step_system.go:136 +internal/installer/step_system.go:155 +internal/installer/step_system.go:169 +internal/installer/step_system.go:176 +internal/installer/step_system.go:185 +internal/installer/step_system.go:187 +internal/installer/step_system.go:196 +internal/installer/step_system.go:202 +internal/installer/step_system.go:203 +internal/installer/step_system.go:217 +internal/installer/step_system.go:221 +internal/macos/macos.go:94 +internal/macos/macos.go:141 +internal/macos/macos.go:153 +internal/npm/npm.go:95 +internal/npm/npm.go:115 +internal/npm/npm.go:131 +internal/npm/npm.go:134 +internal/npm/npm.go:167 +internal/npm/npm.go:185 +internal/npm/npm.go:242 +internal/shell/shell.go:57 +internal/shell/shell.go:129 +internal/shell/shell.go:147 +internal/shell/shell.go:166 +internal/shell/shell.go:199 +internal/shell/shell.go:202 +internal/shell/shell.go:258 +internal/shell/shell.go:274 +internal/shell/shell.go:298 +internal/shell/shell.go:301 +internal/updater/updater.go:189 +internal/updater/updater.go:234 +internal/updater/updater.go:239 +internal/updater/updater.go:251 +internal/updater/updater.go:255 diff --git a/internal/archtest/fmtprint_test.go b/internal/archtest/fmtprint_test.go new file mode 100644 index 0000000..f69b0f8 --- /dev/null +++ b/internal/archtest/fmtprint_test.go @@ -0,0 +1,112 @@ +package archtest + +import ( + "go/ast" + "testing" +) + +// fmtPrintAllowedPaths lists packages that are allowed to use fmt.Print* calls +// directly. internal/ui owns the output helpers and internal/archtest itself +// uses fmt.Fprintf to write baselines. +var fmtPrintAllowedPaths = []string{ + "internal/ui", // owns the output helpers + "internal/archtest", // uses fmt.Fprintf internally for baseline writes +} + +// isOsStderr reports whether the AST expression represents os.Stderr. +// Matches the selector expression os.Stderr, regardless of how "os" is +// aliased in the import block (we check the local name via importedAs). +func isOsStderr(gf goFile, expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return false + } + if sel.Sel.Name != "Stderr" { + return false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + local := importedAs(gf.file, "os") + return local != "" && ident.Name == local +} + +// findFmtPrint finds forbidden fmt.Print*/fmt.F* calls in gf. +// - fmt.Print, fmt.Println, fmt.Printf: always forbidden (stdout only). +// - fmt.Fprint, fmt.Fprintln, fmt.Fprintf: forbidden unless first argument +// is os.Stderr (stderr is acceptable for debug/error output). +func findFmtPrint(gf goFile) []callSite { + local := importedAs(gf.file, "fmt") + if local == "" { + return nil + } + + // stdoutFuncs always write to stdout — always forbidden. + stdoutFuncs := map[string]bool{ + "Print": true, + "Println": true, + "Printf": true, + } + // writerFuncs take an io.Writer as first arg — forbidden unless that writer + // is os.Stderr. + writerFuncs := map[string]bool{ + "Fprint": true, + "Fprintln": true, + "Fprintf": true, + } + + var out []callSite + ast.Inspect(gf.file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != local { + return true + } + fn := sel.Sel.Name + if stdoutFuncs[fn] { + p := gf.fset.Position(call.Pos()) + out = append(out, callSite{file: gf.path, line: p.Line}) + return true + } + if writerFuncs[fn] { + // Only flag if the first argument is NOT os.Stderr. + if len(call.Args) == 0 || !isOsStderr(gf, call.Args[0]) { + p := gf.fset.Position(call.Pos()) + out = append(out, callSite{file: gf.path, line: p.Line}) + } + return true + } + return true + }) + return out +} + +// TestNoRawFmtPrint enforces the CLAUDE.md rule: +// +// UI output must always go through ui.* helpers; raw fmt.Print* calls +// are bugs in user-facing paths. +// +// fmt.Fprintf/Fprintln/Fprint to os.Stderr are exempt — stderr is acceptable +// for debug/error output. +func TestNoRawFmtPrint(t *testing.T) { + r := rule{ + name: "fmtprint", + fix: "Use ui.* helpers (e.g. ui.Println, ui.Printf) instead of fmt.Print*. If writing to os.Stderr for debug/error output, use fmt.Fprintf(os.Stderr, ...) which is exempt.", + } + var violations []callSite + for _, gf := range productionFiles(t) { + if inAllowedPath(gf.path, fmtPrintAllowedPaths) { + continue + } + violations = append(violations, findFmtPrint(gf)...) + } + enforce(t, r, violations) +} diff --git a/internal/cli/install_test.go b/internal/cli/install_test.go index fc0ba9a..c42132e 100644 --- a/internal/cli/install_test.go +++ b/internal/cli/install_test.go @@ -12,7 +12,7 @@ import ( ) func TestApplyEnvOverrides_SilentMode(t *testing.T) { - cfg := &config.Config{Silent: true} + cfg := &config.Config{InstallOptions: config.InstallOptions{Silent: true}} t.Setenv("OPENBOOT_GIT_NAME", "Test User") t.Setenv("OPENBOOT_GIT_EMAIL", "test@example.com") @@ -26,7 +26,7 @@ func TestApplyEnvOverrides_SilentMode(t *testing.T) { } func TestApplyEnvOverrides_GitEnvIgnoredWhenNotSilent(t *testing.T) { - cfg := &config.Config{Silent: false} + cfg := &config.Config{InstallOptions: config.InstallOptions{Silent: false}} t.Setenv("OPENBOOT_GIT_NAME", "Test User") t.Setenv("OPENBOOT_GIT_EMAIL", "test@example.com") @@ -48,7 +48,7 @@ func TestApplyEnvOverrides_UserFromEnv(t *testing.T) { } func TestApplyEnvOverrides_FlagTakesPrecedenceOverEnv(t *testing.T) { - cfg := &config.Config{Preset: "minimal", User: "bob"} + cfg := &config.Config{InstallOptions: config.InstallOptions{Preset: "minimal", User: "bob"}} t.Setenv("OPENBOOT_PRESET", "full") t.Setenv("OPENBOOT_USER", "alice") diff --git a/internal/cli/snapshot_import.go b/internal/cli/snapshot_import.go index 790044c..1cc0f0e 100644 --- a/internal/cli/snapshot_import.go +++ b/internal/cli/snapshot_import.go @@ -164,7 +164,7 @@ func buildImportConfig(edited *snapshot.Snapshot, dryRun bool) *config.Config { } } - cfg := &config.Config{DryRun: dryRun} + cfg := &config.Config{InstallOptions: config.InstallOptions{DryRun: dryRun}} cfg.SelectedPkgs = make(map[string]bool) for _, name := range edited.Packages.Formulae { diff --git a/internal/cli/snapshot_test.go b/internal/cli/snapshot_test.go index a7a79ae..3a361af 100644 --- a/internal/cli/snapshot_test.go +++ b/internal/cli/snapshot_test.go @@ -337,7 +337,7 @@ func TestConfirmInstallation_DryRunSkipsPrompt(t *testing.T) { func TestSaveSyncSourceIfRemote_NilRemoteConfigNoOp(t *testing.T) { t.Setenv("HOME", t.TempDir()) - c := &config.Config{RemoteConfig: nil} + c := &config.Config{} // Should be a no-op — no panic, no file written. saveSyncSourceIfRemote(c) @@ -350,10 +350,12 @@ func TestSaveSyncSourceIfRemote_WithRemoteConfig(t *testing.T) { t.Setenv("HOME", t.TempDir()) c := &config.Config{ - User: "alice/dev-setup", - RemoteConfig: &config.RemoteConfig{ - Username: "alice", - Slug: "dev-setup", + InstallOptions: config.InstallOptions{User: "alice/dev-setup"}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "alice", + Slug: "dev-setup", + }, }, } saveSyncSourceIfRemote(c) diff --git a/internal/config/options.go b/internal/config/options.go index 6d3804c..b435aaf 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -1,53 +1,21 @@ package config -// ToInstallOptions extracts the read-only input fields from Config. +// ToInstallOptions returns a copy of the embedded InstallOptions. +// Plan() may write to opts fields (e.g. opts.Preset) during planning; +// returning a copy preserves the original Config values unchanged. func (c *Config) ToInstallOptions() *InstallOptions { - return &InstallOptions{ - Version: c.Version, - Preset: c.Preset, - User: c.User, - DryRun: c.DryRun, - Silent: c.Silent, - PackagesOnly: c.PackagesOnly, - Update: c.Update, - Shell: c.Shell, - Macos: c.Macos, - Dotfiles: c.Dotfiles, - GitName: c.GitName, - GitEmail: c.GitEmail, - PostInstall: c.PostInstall, - AllowPostInstall: c.AllowPostInstall, - DotfilesURL: c.DotfilesURL, - } + opts := c.InstallOptions + return &opts } -// ToInstallState extracts the mutable runtime fields from Config. +// ToInstallState returns a pointer to the embedded InstallState within Config. +// Zero-copy: callers receive a direct reference to the runtime state fields. func (c *Config) ToInstallState() *InstallState { - return &InstallState{ - SelectedPkgs: c.SelectedPkgs, - OnlinePkgs: c.OnlinePkgs, - SnapshotTaps: c.SnapshotTaps, - RemoteConfig: c.RemoteConfig, - SnapshotGit: c.SnapshotGit, - SnapshotMacOS: c.SnapshotMacOS, - SnapshotDotfiles: c.SnapshotDotfiles, - SnapshotShellOhMyZsh: c.SnapshotShellOhMyZsh, - SnapshotShellTheme: c.SnapshotShellTheme, - SnapshotShellPlugins: c.SnapshotShellPlugins, - } + return &c.InstallState } // ApplyState writes runtime state back into the Config (for callers that still // use *Config as the shared context, e.g. CLI sync/diff commands). func (c *Config) ApplyState(s *InstallState) { - c.SelectedPkgs = s.SelectedPkgs - c.OnlinePkgs = s.OnlinePkgs - c.SnapshotTaps = s.SnapshotTaps - c.RemoteConfig = s.RemoteConfig - c.SnapshotGit = s.SnapshotGit - c.SnapshotMacOS = s.SnapshotMacOS - c.SnapshotDotfiles = s.SnapshotDotfiles - c.SnapshotShellOhMyZsh = s.SnapshotShellOhMyZsh - c.SnapshotShellTheme = s.SnapshotShellTheme - c.SnapshotShellPlugins = s.SnapshotShellPlugins + c.InstallState = *s } diff --git a/internal/config/options_test.go b/internal/config/options_test.go index 3825572..d29c369 100644 --- a/internal/config/options_test.go +++ b/internal/config/options_test.go @@ -12,22 +12,26 @@ import ( func TestToInstallOptions_AllFields(t *testing.T) { rc := &RemoteConfig{Username: "alice", Slug: "setup"} cfg := &Config{ - Version: "1.2.3", - Preset: "developer", - User: "alice", - DryRun: true, - Silent: true, - PackagesOnly: true, - Update: true, - Shell: "install", - Macos: "configure", - Dotfiles: "clone", - GitName: "Alice", - GitEmail: "alice@example.com", - PostInstall: "mise install", - AllowPostInstall: true, - DotfilesURL: "https://github.com/alice/dotfiles", - RemoteConfig: rc, + InstallOptions: InstallOptions{ + Version: "1.2.3", + Preset: "developer", + User: "alice", + DryRun: true, + Silent: true, + PackagesOnly: true, + Update: true, + Shell: "install", + Macos: "configure", + Dotfiles: "clone", + GitName: "Alice", + GitEmail: "alice@example.com", + PostInstall: "mise install", + AllowPostInstall: true, + DotfilesURL: "https://github.com/alice/dotfiles", + }, + InstallState: InstallState{ + RemoteConfig: rc, + }, } opts := cfg.ToInstallOptions() @@ -82,16 +86,18 @@ func TestToInstallState_AllFields(t *testing.T) { } cfg := &Config{ - SelectedPkgs: map[string]bool{"git": true, "curl": false}, - OnlinePkgs: []Package{{Name: "git", Description: "VCS"}}, - SnapshotTaps: []string{"homebrew/core"}, - RemoteConfig: rc, - SnapshotGit: git, - SnapshotMacOS: macOSPrefs, - SnapshotDotfiles: "https://github.com/bob/dotfiles", - SnapshotShellOhMyZsh: true, - SnapshotShellTheme: "agnoster", - SnapshotShellPlugins: []string{"git", "z"}, + InstallState: InstallState{ + SelectedPkgs: map[string]bool{"git": true, "curl": false}, + OnlinePkgs: []Package{{Name: "git", Description: "VCS"}}, + SnapshotTaps: []string{"homebrew/core"}, + RemoteConfig: rc, + SnapshotGit: git, + SnapshotMacOS: macOSPrefs, + SnapshotDotfiles: "https://github.com/bob/dotfiles", + SnapshotShellOhMyZsh: true, + SnapshotShellTheme: "agnoster", + SnapshotShellPlugins: []string{"git", "z"}, + }, } state := cfg.ToInstallState() @@ -167,9 +173,11 @@ func TestApplyState_AllFields(t *testing.T) { func TestApplyState_ZeroState(t *testing.T) { cfg := &Config{ - SelectedPkgs: map[string]bool{"old": true}, - SnapshotDotfiles: "https://github.com/old/dotfiles", - SnapshotShellOhMyZsh: true, + InstallState: InstallState{ + SelectedPkgs: map[string]bool{"old": true}, + SnapshotDotfiles: "https://github.com/old/dotfiles", + SnapshotShellOhMyZsh: true, + }, } state := &InstallState{} @@ -186,14 +194,16 @@ func TestApplyState_ZeroState(t *testing.T) { func TestInstallState_RoundTrip(t *testing.T) { rc := &RemoteConfig{Username: "eve", Slug: "workstation"} original := &Config{ - SelectedPkgs: map[string]bool{"fd": true, "bat": true}, - OnlinePkgs: []Package{{Name: "fd"}, {Name: "bat"}}, - SnapshotTaps: []string{"homebrew/core", "homebrew/cask"}, - RemoteConfig: rc, - SnapshotDotfiles: "https://github.com/eve/dots", - SnapshotShellOhMyZsh: true, - SnapshotShellTheme: "robbyrussell", - SnapshotShellPlugins: []string{"git"}, + InstallState: InstallState{ + SelectedPkgs: map[string]bool{"fd": true, "bat": true}, + OnlinePkgs: []Package{{Name: "fd"}, {Name: "bat"}}, + SnapshotTaps: []string{"homebrew/core", "homebrew/cask"}, + RemoteConfig: rc, + SnapshotDotfiles: "https://github.com/eve/dots", + SnapshotShellOhMyZsh: true, + SnapshotShellTheme: "robbyrussell", + SnapshotShellPlugins: []string{"git"}, + }, } state := original.ToInstallState() @@ -214,8 +224,10 @@ func TestInstallState_RoundTrip(t *testing.T) { // ToInstallOptions does NOT include state fields (runtime-only fields). func TestToInstallOptions_DoesNotLeakStateFields(t *testing.T) { cfg := &Config{ - SelectedPkgs: map[string]bool{"git": true}, - OnlinePkgs: []Package{{Name: "git"}}, + InstallState: InstallState{ + SelectedPkgs: map[string]bool{"git": true}, + OnlinePkgs: []Package{{Name: "git"}}, + }, } opts := cfg.ToInstallOptions() // InstallOptions has no SelectedPkgs or OnlinePkgs — simply confirm it compiles diff --git a/internal/config/types.go b/internal/config/types.go index 2af46ed..fbac230 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -5,12 +5,9 @@ import ( "fmt" ) -// Config holds all configuration for a single openboot run. -// See InstallOptions and InstallState for the split representation used -// internally by the installer package. -type Config struct { - // --- Input (set by flags/env before run) --- - +// InstallOptions holds user-supplied inputs set from CLI flags and environment +// variables. All fields are read-only after Run() is called. +type InstallOptions struct { Version string // injected via -ldflags at build time Preset string // -p / OPENBOOT_PRESET User string // -u / OPENBOOT_USER @@ -26,9 +23,11 @@ type Config struct { PostInstall string // --post-install AllowPostInstall bool // --allow-post-install DotfilesURL string // from remote config +} - // --- Runtime state (populated during install) --- - +// InstallState holds runtime values populated during installation. +// Fields are written by installer steps and read by subsequent steps. +type InstallState struct { SelectedPkgs map[string]bool // set by UI package selector OnlinePkgs []Package // fetched from packages API SnapshotTaps []string // from snapshot capture @@ -41,39 +40,13 @@ type Config struct { SnapshotShellPlugins []string // from snapshot capture } -// InstallOptions holds user-supplied inputs set from CLI flags and environment -// variables. All fields are read-only after Run() is called. -type InstallOptions struct { - Version string - Preset string - User string - DryRun bool - Silent bool - PackagesOnly bool - Update bool - Shell string - Macos string - Dotfiles string - GitName string - GitEmail string - PostInstall string - AllowPostInstall bool - DotfilesURL string -} - -// InstallState holds runtime values populated during installation. -// Fields are written by installer steps and read by subsequent steps. -type InstallState struct { - SelectedPkgs map[string]bool - OnlinePkgs []Package - SnapshotTaps []string - RemoteConfig *RemoteConfig - SnapshotGit *SnapshotGitConfig - SnapshotMacOS []RemoteMacOSPref - SnapshotDotfiles string - SnapshotShellOhMyZsh bool - SnapshotShellTheme string - SnapshotShellPlugins []string +// Config holds all configuration for a single openboot run. +// It embeds InstallOptions (input fields) and InstallState (runtime state) +// so that adding one field requires editing at most InstallOptions or +// InstallState — not Config separately. +type Config struct { + InstallOptions // all input fields (flags/env) + InstallState // all runtime state (populated during install) } type SnapshotGitConfig struct { diff --git a/internal/installer/installer_extra_test.go b/internal/installer/installer_extra_test.go index 4762680..2471013 100644 --- a/internal/installer/installer_extra_test.go +++ b/internal/installer/installer_extra_test.go @@ -225,12 +225,16 @@ func TestApply_DryRun_SkipGit(t *testing.T) { func TestPlan_RemoteConfig_Taps(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}}, - Taps: []string{"homebrew/cask", "homebrew/core"}, + InstallOptions: config.InstallOptions{ + DryRun: true, + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}}, + Taps: []string{"homebrew/cask", "homebrew/core"}, + }, }, } opts := cfg.ToInstallOptions() @@ -242,11 +246,13 @@ func TestPlan_RemoteConfig_Taps(t *testing.T) { func TestPlan_RemoteConfig_NpmPackages(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Npm: config.PackageEntryList{{Name: "typescript"}, {Name: "eslint"}}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Npm: config.PackageEntryList{{Name: "typescript"}, {Name: "eslint"}}, + }, }, } opts := cfg.ToInstallOptions() @@ -259,11 +265,13 @@ func TestPlan_RemoteConfig_NpmPackages(t *testing.T) { func TestPlan_RemoteConfig_ShellOhMyZsh(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Shell: &config.RemoteShellConfig{OhMyZsh: true}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Shell: &config.RemoteShellConfig{OhMyZsh: true}, + }, }, } opts := cfg.ToInstallOptions() @@ -275,12 +283,14 @@ func TestPlan_RemoteConfig_ShellOhMyZsh(t *testing.T) { func TestPlan_RemoteConfig_MacOSPrefs(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - MacOSPrefs: []config.RemoteMacOSPref{ - {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true", Desc: "Auto-hide dock"}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + MacOSPrefs: []config.RemoteMacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true", Desc: "Auto-hide dock"}, + }, }, }, } @@ -296,12 +306,14 @@ func TestPlan_RemoteConfig_MacOSPrefs(t *testing.T) { func TestPlan_RemoteConfig_MacOSPrefs_InferredType(t *testing.T) { // When Type is empty, planFromRemoteConfig should infer it. cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - MacOSPrefs: []config.RemoteMacOSPref{ - {Domain: "com.apple.dock", Key: "autohide", Type: "", Value: "true"}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + MacOSPrefs: []config.RemoteMacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Type: "", Value: "true"}, + }, }, }, } @@ -316,11 +328,13 @@ func TestPlan_RemoteConfig_MacOSPrefs_InferredType(t *testing.T) { func TestPlan_RemoteConfig_PostInstall(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - PostInstall: []string{"mise install", "npm install -g pnpm"}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + PostInstall: []string{"mise install", "npm install -g pnpm"}, + }, }, } opts := cfg.ToInstallOptions() @@ -333,12 +347,16 @@ func TestPlan_RemoteConfig_PostInstall(t *testing.T) { func TestPlan_RemoteConfig_DotfilesFromOpts(t *testing.T) { // When RemoteConfig has no DotfilesRepo, fall back to opts.DotfilesURL. cfg := &config.Config{ - DryRun: true, - DotfilesURL: "https://github.com/opts/dotfiles", - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - DotfilesRepo: "", + InstallOptions: config.InstallOptions{ + DryRun: true, + DotfilesURL: "https://github.com/opts/dotfiles", + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + DotfilesRepo: "", + }, }, } opts := cfg.ToInstallOptions() diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go index fddd0e3..66d3e0d 100644 --- a/internal/installer/installer_test.go +++ b/internal/installer/installer_test.go @@ -40,7 +40,9 @@ func TestEstimateInstallMinutes(t *testing.T) { func TestCategorizeSelectedPackages_EmptySelection(t *testing.T) { cfg := &config.Config{ - SelectedPkgs: map[string]bool{}, + InstallState: config.InstallState{ + SelectedPkgs: map[string]bool{}, + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -52,15 +54,17 @@ func TestCategorizeSelectedPackages_EmptySelection(t *testing.T) { func TestCategorizeSelectedPackages_RemoteConfig(t *testing.T) { cfg := &config.Config{ - RemoteConfig: &config.RemoteConfig{ - Casks: config.PackageEntryList{{Name: "visual-studio-code"}, {Name: "firefox"}}, - Npm: config.PackageEntryList{{Name: "typescript"}, {Name: "eslint"}}, - }, - SelectedPkgs: map[string]bool{ - "git": true, - "visual-studio-code": true, - "typescript": true, - "curl": true, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Casks: config.PackageEntryList{{Name: "visual-studio-code"}, {Name: "firefox"}}, + Npm: config.PackageEntryList{{Name: "typescript"}, {Name: "eslint"}}, + }, + SelectedPkgs: map[string]bool{ + "git": true, + "visual-studio-code": true, + "typescript": true, + "curl": true, + }, }, } opts := cfg.ToInstallOptions() @@ -75,13 +79,15 @@ func TestCategorizeSelectedPackages_RemoteConfig(t *testing.T) { func TestCategorizeSelectedPackages_RemoteConfig_NoCasks(t *testing.T) { cfg := &config.Config{ - RemoteConfig: &config.RemoteConfig{ - Casks: config.PackageEntryList{}, - Npm: config.PackageEntryList{}, - }, - SelectedPkgs: map[string]bool{ - "git": true, - "curl": true, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Casks: config.PackageEntryList{}, + Npm: config.PackageEntryList{}, + }, + SelectedPkgs: map[string]bool{ + "git": true, + "curl": true, + }, }, } opts := cfg.ToInstallOptions() @@ -95,11 +101,13 @@ func TestCategorizeSelectedPackages_RemoteConfig_NoCasks(t *testing.T) { func TestCategorizeSelectedPackages_WithOnlinePkgs(t *testing.T) { cfg := &config.Config{ - SelectedPkgs: map[string]bool{}, - OnlinePkgs: []config.Package{ - {Name: "my-formula", IsCask: false, IsNpm: false}, - {Name: "my-cask", IsCask: true, IsNpm: false}, - {Name: "my-npm-pkg", IsCask: false, IsNpm: true}, + InstallState: config.InstallState{ + SelectedPkgs: map[string]bool{}, + OnlinePkgs: []config.Package{ + {Name: "my-formula", IsCask: false, IsNpm: false}, + {Name: "my-cask", IsCask: true, IsNpm: false}, + {Name: "my-npm-pkg", IsCask: false, IsNpm: true}, + }, }, } opts := cfg.ToInstallOptions() @@ -113,8 +121,10 @@ func TestCategorizeSelectedPackages_WithOnlinePkgs(t *testing.T) { func TestRun_UpdateRoute(t *testing.T) { cfg := &config.Config{ - Update: true, - DryRun: true, + InstallOptions: config.InstallOptions{ + Update: true, + DryRun: true, + }, } err := Run(cfg) assert.NoError(t, err) @@ -122,7 +132,7 @@ func TestRun_UpdateRoute(t *testing.T) { func TestCheckDependencies_DryRunSkipsEverything(t *testing.T) { cfg := &config.Config{ - DryRun: true, + InstallOptions: config.InstallOptions{DryRun: true}, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -132,13 +142,15 @@ func TestCheckDependencies_DryRunSkipsEverything(t *testing.T) { func TestRunInstall_DryRunRemoteConfig(t *testing.T) { cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}, {Name: "curl"}}, - Casks: config.PackageEntryList{{Name: "firefox"}}, - Taps: []string{"homebrew/cask"}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}, {Name: "curl"}}, + Casks: config.PackageEntryList{{Name: "firefox"}}, + Taps: []string{"homebrew/cask"}, + }, }, } @@ -264,104 +276,9 @@ func TestErrUserCancelled(t *testing.T) { assert.Equal(t, "user cancelled", ErrUserCancelled.Error()) } -func TestStepGitConfig_DryRunNoTTY(t *testing.T) { - cfg := &config.Config{ - DryRun: true, - GitName: "Test", - GitEmail: "test@example.com", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepGitConfig(opts, st) - assert.NoError(t, err) -} - -func TestStepGitConfig_SilentMode_MissingFields(t *testing.T) { - cfg := &config.Config{ - Silent: true, - GitName: "", - GitEmail: "", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - - err := stepGitConfig(opts, st) - if err != nil { - assert.Contains(t, err.Error(), "required in silent mode") - } -} - -func TestStepPresetSelection_PresetAlreadySet(t *testing.T) { - cfg := &config.Config{ - Preset: "minimal", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPresetSelection(opts, st) - assert.NoError(t, err) -} - -func TestStepPresetSelection_ScratchPreset(t *testing.T) { - cfg := &config.Config{ - Preset: "scratch", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPresetSelection(opts, st) - assert.NoError(t, err) -} - -func TestStepPresetSelection_InvalidPreset(t *testing.T) { - cfg := &config.Config{ - Preset: "nonexistent_preset", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPresetSelection(opts, st) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid preset") -} - -func TestStepPresetSelection_SilentDefaultsToMinimal(t *testing.T) { - cfg := &config.Config{ - Silent: true, - Preset: "", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPresetSelection(opts, st) - assert.NoError(t, err) - assert.Equal(t, "minimal", opts.Preset) -} - -func TestStepPackageCustomization_Silent(t *testing.T) { - cfg := &config.Config{ - Silent: true, - Preset: "minimal", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPackageCustomization(opts, st) - assert.NoError(t, err) - assert.NotNil(t, st.SelectedPkgs) - assert.Greater(t, len(st.SelectedPkgs), 0) -} - -func TestStepPackageCustomization_DryRunNoTTY(t *testing.T) { - cfg := &config.Config{ - DryRun: true, - Preset: "developer", - } - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - err := stepPackageCustomization(opts, st) - assert.NoError(t, err) - assert.NotNil(t, st.SelectedPkgs) -} - func TestStepShell_Skip(t *testing.T) { cfg := &config.Config{ - Shell: "skip", + InstallOptions: config.InstallOptions{Shell: "skip"}, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -371,7 +288,7 @@ func TestStepShell_Skip(t *testing.T) { func TestStepDotfiles_Skip(t *testing.T) { cfg := &config.Config{ - Dotfiles: "skip", + InstallOptions: config.InstallOptions{Dotfiles: "skip"}, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -381,7 +298,7 @@ func TestStepDotfiles_Skip(t *testing.T) { func TestStepMacOS_Skip(t *testing.T) { cfg := &config.Config{ - Macos: "skip", + InstallOptions: config.InstallOptions{Macos: "skip"}, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -392,8 +309,10 @@ func TestStepMacOS_Skip(t *testing.T) { func TestStepMacOS_ConfigureFlag_DryRun(t *testing.T) { // --macos configure bypasses the TUI and applies all defaults directly. cfg := &config.Config{ - Macos: "configure", - DryRun: true, + InstallOptions: config.InstallOptions{ + Macos: "configure", + DryRun: true, + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -404,8 +323,10 @@ func TestStepMacOS_ConfigureFlag_DryRun(t *testing.T) { func TestStepMacOS_Silent_DryRun(t *testing.T) { // Silent mode bypasses the TUI and applies all defaults directly. cfg := &config.Config{ - Silent: true, - DryRun: true, + InstallOptions: config.InstallOptions{ + Silent: true, + DryRun: true, + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -439,10 +360,14 @@ func TestRunInstall_PackagesOnly_DryRun(t *testing.T) { t.Setenv("HOME", tmpDir) cfg := &config.Config{ - DryRun: true, - Preset: "minimal", - PackagesOnly: true, - SelectedPkgs: map[string]bool{}, + InstallOptions: config.InstallOptions{ + DryRun: true, + Preset: "minimal", + PackagesOnly: true, + }, + InstallState: config.InstallState{ + SelectedPkgs: map[string]bool{}, + }, } opts := cfg.ToInstallOptions() @@ -456,14 +381,18 @@ func TestRunFromSnapshot_SoftFailuresReturnError(t *testing.T) { t.Setenv("HOME", tmpDir) cfg := &config.Config{ - DryRun: true, - Silent: true, - Preset: "minimal", - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - SelectedPkgs: map[string]bool{}, - SnapshotGit: nil, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Preset: "minimal", + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SelectedPkgs: map[string]bool{}, + SnapshotGit: nil, + }, } err := RunFromSnapshot(cfg) @@ -476,14 +405,18 @@ func TestApply_RemoteConfig_RunsShellDotfilesMacOS(t *testing.T) { t.Setenv("OPENBOOT_DOTFILES", "") cfg := &config.Config{ - DryRun: true, - PackagesOnly: true, - Shell: "skip", - Macos: "skip", - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}}, + InstallOptions: config.InstallOptions{ + DryRun: true, + PackagesOnly: true, + Shell: "skip", + Macos: "skip", + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}}, + }, }, } @@ -500,15 +433,19 @@ func TestPlan_RemoteConfig_DotfilesRepoPopulatesDotfilesURL(t *testing.T) { t.Setenv("HOME", tmpDir) cfg := &config.Config{ - DryRun: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}}, - DotfilesRepo: "https://github.com/testuser/dotfiles", + InstallOptions: config.InstallOptions{ + DryRun: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}}, + DotfilesRepo: "https://github.com/testuser/dotfiles", + }, }, } @@ -524,16 +461,20 @@ func TestApply_RemoteConfig_DotfilesFallsBackToDefault(t *testing.T) { t.Setenv("HOME", tmpDir) cfg := &config.Config{ - DryRun: true, - PackagesOnly: true, - Shell: "skip", - Macos: "skip", - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}}, + InstallOptions: config.InstallOptions{ + DryRun: true, + PackagesOnly: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "link", + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}}, + }, }, - Dotfiles: "link", } opts := cfg.ToInstallOptions() @@ -550,9 +491,11 @@ func TestStepDotfiles_UsesDotfilesURLFromConfig(t *testing.T) { t.Setenv("OPENBOOT_DOTFILES", "") cfg := &config.Config{ - DryRun: true, - Dotfiles: "clone", - DotfilesURL: "https://github.com/testuser/dotfiles", + InstallOptions: config.InstallOptions{ + DryRun: true, + Dotfiles: "clone", + DotfilesURL: "https://github.com/testuser/dotfiles", + }, } opts := cfg.ToInstallOptions() @@ -571,9 +514,11 @@ func TestStepDotfiles_EnvVarTakesPriorityOverConfigURL(t *testing.T) { os.Stdout = w cfg := &config.Config{ - DryRun: true, - Dotfiles: "clone", - DotfilesURL: "https://github.com/from-config/dotfiles", + InstallOptions: config.InstallOptions{ + DryRun: true, + Dotfiles: "clone", + DotfilesURL: "https://github.com/from-config/dotfiles", + }, } opts := cfg.ToInstallOptions() @@ -592,9 +537,11 @@ func TestStepDotfiles_EnvVarTakesPriorityOverConfigURL(t *testing.T) { func TestStepPostInstall_SkipFlag(t *testing.T) { cfg := &config.Config{ - PostInstall: "skip", - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{"echo hello"}, + InstallOptions: config.InstallOptions{PostInstall: "skip"}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{"echo hello"}, + }, }, } opts := cfg.ToInstallOptions() @@ -613,8 +560,10 @@ func TestStepPostInstall_NilRemoteConfig(t *testing.T) { func TestStepPostInstall_EmptyCommands(t *testing.T) { cfg := &config.Config{ - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{}, + }, }, } opts := cfg.ToInstallOptions() @@ -632,9 +581,11 @@ func TestStepPostInstall_DryRun(t *testing.T) { os.Stdout = w cfg := &config.Config{ - DryRun: true, - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{"mise install", "npm install -g pnpm"}, + InstallOptions: config.InstallOptions{DryRun: true}, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{"mise install", "npm install -g pnpm"}, + }, }, } @@ -660,10 +611,14 @@ func TestStepPostInstall_RunsCommandsInSilentMode(t *testing.T) { markerFile := tmpDir + "/post-install-ran" cfg := &config.Config{ - Silent: true, - AllowPostInstall: true, - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{"touch " + markerFile}, + InstallOptions: config.InstallOptions{ + Silent: true, + AllowPostInstall: true, + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{"touch " + markerFile}, + }, }, } @@ -682,10 +637,14 @@ func TestStepPostInstall_CommandFailureReturnsSoftError(t *testing.T) { t.Setenv("HOME", tmpDir) cfg := &config.Config{ - Silent: true, - AllowPostInstall: true, - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{"exit 1"}, + InstallOptions: config.InstallOptions{ + Silent: true, + AllowPostInstall: true, + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{"exit 1"}, + }, }, } @@ -703,14 +662,18 @@ func TestStepPostInstall_MultilineScriptRuns(t *testing.T) { markerFile := tmpDir + "/multiline-ran" cfg := &config.Config{ - Silent: true, - AllowPostInstall: true, - RemoteConfig: &config.RemoteConfig{ - PostInstall: []string{ - "ITEMS=(a b c)", - "for item in \"${ITEMS[@]}\"; do", - " touch " + markerFile, - "done", + InstallOptions: config.InstallOptions{ + Silent: true, + AllowPostInstall: true, + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + PostInstall: []string{ + "ITEMS=(a b c)", + "for item in \"${ITEMS[@]}\"; do", + " touch " + markerFile, + "done", + }, }, }, } @@ -803,15 +766,19 @@ func TestApply_RemoteConfig_WithPostInstallScript(t *testing.T) { t.Setenv("OPENBOOT_DOTFILES", "") cfg := &config.Config{ - DryRun: true, - PackagesOnly: true, - Shell: "skip", - Macos: "skip", - RemoteConfig: &config.RemoteConfig{ - Username: "testuser", - Slug: "default", - Packages: config.PackageEntryList{{Name: "git"}}, - PostInstall: []string{"mise install", "npm install -g pnpm"}, + InstallOptions: config.InstallOptions{ + DryRun: true, + PackagesOnly: true, + Shell: "skip", + Macos: "skip", + }, + InstallState: config.InstallState{ + RemoteConfig: &config.RemoteConfig{ + Username: "testuser", + Slug: "default", + Packages: config.PackageEntryList{{Name: "git"}}, + PostInstall: []string{"mise install", "npm install -g pnpm"}, + }, }, } diff --git a/internal/installer/plan_test.go b/internal/installer/plan_test.go index 53f02e6..298ccd5 100644 --- a/internal/installer/plan_test.go +++ b/internal/installer/plan_test.go @@ -236,9 +236,11 @@ func TestPlanMacOSDecision_DryRunNoTTY(t *testing.T) { func TestPlanInteractive_PackagesOnly_DryRun(t *testing.T) { cfg := &config.Config{ - DryRun: true, - PackagesOnly: true, - Preset: "minimal", + InstallOptions: config.InstallOptions{ + DryRun: true, + PackagesOnly: true, + Preset: "minimal", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -258,13 +260,14 @@ func TestPlanInteractive_Silent_Preset_Minimal(t *testing.T) { t.Setenv("OPENBOOT_DOTFILES", "") cfg := &config.Config{ - Silent: true, - Preset: "minimal", - Shell: "skip", - Macos: "skip", - // GitName/GitEmail populated or left empty — silent path handles it. - GitName: "CI User", - GitEmail: "ci@example.com", + InstallOptions: config.InstallOptions{ + Silent: true, + Preset: "minimal", + Shell: "skip", + Macos: "skip", + GitName: "CI User", + GitEmail: "ci@example.com", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -279,11 +282,13 @@ func TestPlanInteractive_DryRun_Minimal_AllSkipped(t *testing.T) { t.Setenv("OPENBOOT_DOTFILES", "") cfg := &config.Config{ - DryRun: true, - Preset: "minimal", - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", + InstallOptions: config.InstallOptions{ + DryRun: true, + Preset: "minimal", + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -305,12 +310,16 @@ func TestPlanInteractive_DryRun_Minimal_AllSkipped(t *testing.T) { func TestPlanFromSnapshot_GitNil_SetsSkipGit(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - SnapshotGit: nil, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotGit: nil, + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -323,14 +332,18 @@ func TestPlanFromSnapshot_GitNil_SetsSkipGit(t *testing.T) { func TestPlanFromSnapshot_GitPresent_PopulatesNameEmail(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - SnapshotGit: &config.SnapshotGitConfig{ - UserName: "Snap User", - UserEmail: "snap@example.com", + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotGit: &config.SnapshotGitConfig{ + UserName: "Snap User", + UserEmail: "snap@example.com", + }, }, } opts := cfg.ToInstallOptions() @@ -344,12 +357,16 @@ func TestPlanFromSnapshot_GitPresent_PopulatesNameEmail(t *testing.T) { func TestPlanFromSnapshot_DotfilesSkipFlag(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - SnapshotDotfiles: "https://github.com/user/dotfiles", + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotDotfiles: "https://github.com/user/dotfiles", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -361,11 +378,15 @@ func TestPlanFromSnapshot_DotfilesSkipFlag(t *testing.T) { func TestPlanFromSnapshot_DotfilesFromSnapshot(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - SnapshotDotfiles: "https://github.com/user/dotfiles", + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + }, + InstallState: config.InstallState{ + SnapshotDotfiles: "https://github.com/user/dotfiles", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -376,13 +397,17 @@ func TestPlanFromSnapshot_DotfilesFromSnapshot(t *testing.T) { func TestPlanFromSnapshot_ShellRestored(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Macos: "skip", - Dotfiles: "skip", - SnapshotShellOhMyZsh: true, - SnapshotShellTheme: "robbyrussell", - SnapshotShellPlugins: []string{"git", "kubectl"}, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotShellOhMyZsh: true, + SnapshotShellTheme: "robbyrussell", + SnapshotShellPlugins: []string{"git", "kubectl"}, + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -395,13 +420,17 @@ func TestPlanFromSnapshot_ShellRestored(t *testing.T) { func TestPlanFromSnapshot_ShellSkipFlag(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - SnapshotShellOhMyZsh: true, - SnapshotShellTheme: "robbyrussell", + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotShellOhMyZsh: true, + SnapshotShellTheme: "robbyrussell", + }, } opts := cfg.ToInstallOptions() st := cfg.ToInstallState() @@ -412,12 +441,16 @@ func TestPlanFromSnapshot_ShellSkipFlag(t *testing.T) { func TestPlanFromSnapshot_MacOSPrefsFromSnapshot(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Dotfiles: "skip", - SnapshotMacOS: []config.RemoteMacOSPref{ - {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true"}, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + SnapshotMacOS: []config.RemoteMacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true"}, + }, }, } opts := cfg.ToInstallOptions() @@ -431,13 +464,17 @@ func TestPlanFromSnapshot_MacOSPrefsFromSnapshot(t *testing.T) { func TestPlanFromSnapshot_MacOSSkipFlag(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Dotfiles: "skip", - Macos: "skip", - SnapshotMacOS: []config.RemoteMacOSPref{ - {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true"}, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Dotfiles: "skip", + Macos: "skip", + }, + InstallState: config.InstallState{ + SnapshotMacOS: []config.RemoteMacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Type: "bool", Value: "true"}, + }, }, } opts := cfg.ToInstallOptions() @@ -449,15 +486,19 @@ func TestPlanFromSnapshot_MacOSSkipFlag(t *testing.T) { func TestPlanFromSnapshot_PackageCategorizationFromSelectedPkgs(t *testing.T) { cfg := &config.Config{ - DryRun: true, - Silent: true, - Shell: "skip", - Macos: "skip", - Dotfiles: "skip", - // Use packages that are known to the local catalog so they get - // categorized correctly without a remote config. - SelectedPkgs: map[string]bool{ - "curl": true, + InstallOptions: config.InstallOptions{ + DryRun: true, + Silent: true, + Shell: "skip", + Macos: "skip", + Dotfiles: "skip", + }, + InstallState: config.InstallState{ + // Use packages that are known to the local catalog so they get + // categorized correctly without a remote config. + SelectedPkgs: map[string]bool{ + "curl": true, + }, }, } opts := cfg.ToInstallOptions() diff --git a/internal/installer/reminder_test.go b/internal/installer/reminder_test.go index 87b09e3..964790e 100644 --- a/internal/installer/reminder_test.go +++ b/internal/installer/reminder_test.go @@ -90,8 +90,10 @@ func TestFindMatchingPackages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.Config{ - SelectedPkgs: tt.selectedPkgs, - OnlinePkgs: tt.onlinePkgs, + InstallState: config.InstallState{ + SelectedPkgs: tt.selectedPkgs, + OnlinePkgs: tt.onlinePkgs, + }, } result := findMatchingPackages(cfg.ToInstallOptions(), cfg.ToInstallState(), tt.triggerPkgs) assert.Len(t, result, tt.wantCount) diff --git a/internal/installer/step_git.go b/internal/installer/step_git.go index c0a7683..615c5ef 100644 --- a/internal/installer/step_git.go +++ b/internal/installer/step_git.go @@ -3,9 +3,7 @@ package installer import ( "fmt" - "github.com/openbootdotdev/openboot/internal/config" "github.com/openbootdotdev/openboot/internal/system" - "github.com/openbootdotdev/openboot/internal/ui" ) func applyGitConfig(plan InstallPlan, r Reporter) error { @@ -37,58 +35,3 @@ func applyGitConfig(plan InstallPlan, r Reporter) error { fmt.Println() return nil } - -func stepGitConfig(opts *config.InstallOptions, st *config.InstallState) error { - ui.Header("Step 1: Git Configuration") - fmt.Println() - - // Smart detection: skip if already configured - existingName, existingEmail := system.GetExistingGitConfig() - - if existingName != "" && existingEmail != "" { - ui.Success(fmt.Sprintf("✓ Already configured: %s <%s>", existingName, existingEmail)) - fmt.Println() - return nil - } - - var name, email string - - if opts.DryRun && !system.HasTTY() { - name = opts.GitName - email = opts.GitEmail - if name == "" { - name = "Your Name" - } - if email == "" { - email = "you@example.com" - } - } else if opts.Silent { - name = opts.GitName - email = opts.GitEmail - if name == "" || email == "" { - return fmt.Errorf("OPENBOOT_GIT_NAME and OPENBOOT_GIT_EMAIL required in silent mode") - } - } else { - var err error - name, email, err = ui.InputGitConfig() - if err != nil { - return err - } - } - - if name == "" || email == "" { - return fmt.Errorf("git name and email are required") - } - - if opts.DryRun { - fmt.Printf("[DRY-RUN] Would configure git: %s <%s>\n", name, email) - } else { - if err := system.ConfigureGit(name, email); err != nil { - return err - } - ui.Success(fmt.Sprintf("Git configured: %s <%s>", name, email)) - } - - fmt.Println() - return nil -} diff --git a/internal/installer/step_packages.go b/internal/installer/step_packages.go index d294765..026ae2a 100644 --- a/internal/installer/step_packages.go +++ b/internal/installer/step_packages.go @@ -9,7 +9,6 @@ import ( "github.com/openbootdotdev/openboot/internal/config" "github.com/openbootdotdev/openboot/internal/npm" "github.com/openbootdotdev/openboot/internal/system" - "github.com/openbootdotdev/openboot/internal/ui" ) type categorizedPackages struct { @@ -72,97 +71,6 @@ func categorizeSelectedPackages(opts *config.InstallOptions, st *config.InstallS return result } -func stepPresetSelection(opts *config.InstallOptions, st *config.InstallState) error { - ui.Header("Step 2: Preset Selection") - fmt.Println() - - if opts.Preset == "" { - if opts.Silent || (opts.DryRun && !system.HasTTY()) { - opts.Preset = "minimal" - } else { - var err error - opts.Preset, err = ui.SelectPreset() - if err != nil { - return err - } - } - } - - // Handle "scratch" as special case - use minimal but show full catalog - if opts.Preset == "scratch" { - ui.Success("Selected: scratch (choose from full catalog)") - ui.Muted("You'll be able to search and select individual packages") - fmt.Println() - return nil - } - - preset, ok := config.GetPreset(opts.Preset) - if !ok { - return fmt.Errorf("invalid preset: %s", opts.Preset) - } - - ui.Success(fmt.Sprintf("Selected preset: %s", preset.Name)) - ui.Info(fmt.Sprintf("CLI packages: %d", len(preset.CLI))) - ui.Info(fmt.Sprintf("GUI applications: %d", len(preset.Cask))) - if len(preset.Npm) > 0 { - ui.Info(fmt.Sprintf("npm packages: %d", len(preset.Npm))) - } - - fmt.Println() - return nil -} - -func stepPackageCustomization(opts *config.InstallOptions, st *config.InstallState) error { - ui.Header("Step 3: Package Selection") - fmt.Println() - - if opts.Silent || (opts.DryRun && !system.HasTTY()) { - st.SelectedPkgs = config.GetPackagesForPreset(opts.Preset) - total := len(st.SelectedPkgs) - ui.Info(fmt.Sprintf("Using preset packages: %d selected", total)) - fmt.Println() - return nil - } - - ui.Info("Customize your packages (based on preset: " + opts.Preset + ")") - ui.Muted("Use Tab to switch categories, Space to toggle, Enter to confirm") - fmt.Println() - - selected, onlinePkgs, confirmed, err := ui.RunSelector(opts.Preset) - if err != nil { - return err - } - - if !confirmed { - ui.Muted("Installation cancelled.") - return ErrUserCancelled - } - - st.SelectedPkgs = selected - st.OnlinePkgs = onlinePkgs - - if st.RemoteConfig != nil && len(st.RemoteConfig.Packages) > 0 { - for _, pkg := range st.RemoteConfig.Packages { - st.SelectedPkgs[pkg.Name] = true - } - } - if st.RemoteConfig != nil && len(st.RemoteConfig.Casks) > 0 { - for _, cask := range st.RemoteConfig.Casks { - st.SelectedPkgs[cask.Name] = true - } - } - - count := 0 - for _, v := range selected { - if v { - count++ - } - } - ui.Success(fmt.Sprintf("Selected %d packages", count)) - fmt.Println() - return nil -} - func applyPackages(plan InstallPlan, r Reporter) error { //nolint:gocyclo // orchestrates multiple package categories; splitting would obscure the install sequence r.Header("Step 4: Installation") fmt.Println()