Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Core flow: `openboot install` orchestrates plan -> apply in `internal/installer/

**The wizard plans; the apply is always linear.** On a TTY the planning phase runs as a full-screen TUI in `internal/ui/tui/wizard/` (boot probe -> select -> git -> review), then the wizard *exits* and hands its `InstallPlan` to `installer.ApplyReviewedPlan`, which applies it on the normal terminal with `ConsoleReporter` + `ui.StickyProgress`. This split is deliberate: a TUI is right for browsing a 100+ package catalog, and wrong for the apply — an alt-screen install discards its own output when it exits, so twenty minutes of package results and failures vanish. Streamed into the scrollback they stay where the user can scroll back, copy an error, and pipe it. Don't move the apply back inside the alt-screen.

Entry points: bare `install` -> `wizard.Run`; `-p <preset>` -> same, loadout preselected; slug/`-u`/`--from`/alias -> `wizard.RunForConfig` (config mode: the config's own packages on the select screen, preselected). Sync-source installs keep their linear diff pre-flight and apply linearly. `--silent`, `--dry-run`, `--update`, `--pick`, and non-TTY runs never enter the wizard.
Entry points: bare `install` -> `wizard.Run`; `-p <preset>` -> same, loadout preselected; slug/`-u`/`--from`/alias -> `wizard.RunForConfig` (config mode: the config's own packages on the select screen, preselected). TTY `--dry-run` uses the same wizard and carries preview-only semantics into apply. Sync-source installs keep their linear diff pre-flight and apply linearly. `--silent`, `--update`, `--pick`, and non-TTY runs never enter the wizard.

## Working in parallel

Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ require (
github.com/charmbracelet/bubbletea v1.3.0
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/sahilm/fuzzy v0.1.1
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.39.0
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
Expand All @@ -53,8 +51,6 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
Expand Down
49 changes: 32 additions & 17 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func init() {
installCmd.Flags().StringVar(&installCfg.Dotfiles, "dotfiles", "", "dotfiles: clone, link, skip")
installCmd.Flags().StringVar(&installCfg.PostInstall, "post-install", "", "post-install script: skip")

installCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew before installing")
installCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew and exit")
installCmd.Flags().BoolVar(&installCfg.AllowPostInstall, "allow-post-install", false, "allow post-install scripts in silent mode")
}

Expand All @@ -101,9 +101,16 @@ func applyEnvOverrides(cfg *config.Config) {
}
}

func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo // top-level install dispatch: routes source (sync / wizard / RemoteConfig) × mode (pick / silent / dry-run / TTY-pipeline / linear); splitting the branch table scatters the flow
func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo // top-level install dispatch: routes source (sync / wizard / RemoteConfig) × mode (pick / silent / dry-run / update / linear); splitting the branch table scatters the flow
applyEnvOverrides(installCfg)

// --update is a standalone Homebrew maintenance mode. It does not consume
// an install source or make package-selection decisions, so keep it out of
// every wizard/customizer branch (including a saved sync source).
if installCfg.Update {
return installer.RunContext(cmd.Context(), installCfg)
}

if installCfg.RemoteConfig == nil {
src, err := resolveInstallSource(cmd, args)
if err != nil {
Expand Down Expand Up @@ -135,11 +142,11 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo /
return perr
}
installCfg.RemoteConfig = rc
} else if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() {
} else if wizardMode(installCfg, system.HasTTY()) {
// The full-screen config wizard owns the whole interactive flow
// (select the config's packages → review → live install) —
// (select the config's packages → review → linear apply) —
// replacing the linear 3-way prompt + customizer that used to
// precede the pipeline screen for slug / -u / --from / alias.
// handle slug / -u / --from / alias, including dry-run previews.
return runConfigWizard(cmd.Context(), installCfg.RemoteConfig)
} else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) {
rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig)
Expand All @@ -153,28 +160,31 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo /
installCfg.RemoteConfig = rc
}
} else if pickRaw != "" {
return fmt.Errorf("--pick requires a remote config; use the preset selector instead")
return fmt.Errorf("--pick requires a remote config; use the interactive wizard instead")
}

err := installer.RunContext(cmd.Context(), installCfg)
if errors.Is(err, installer.ErrUserCancelled) {
return nil
}
if err == nil && !installCfg.DryRun {
saveSyncSourceIfRemote(installCfg)
}
return err
}

// shouldLaunchWizard reports whether this run gets the full-screen wizard
// (boot probe → select → live install) on a TTY: a bare `openboot install`,
// (boot probe → select → git → review) on a TTY: a bare `openboot install`,
// or a valid preset (-p / OPENBOOT_PRESET / positional), which enters the
// wizard with that loadout preselected on the select screen. Remote sources
// (--from, -u, slug, sync), --silent, --dry-run, and --update keep their
// existing flows.
// (--from, -u, slug, sync), --silent, --update, and non-TTY runs keep their
// dedicated flows. Dry-run is still interactive on a TTY; the returned plan
// carries DryRun through the normal preview-only apply.
func shouldLaunchWizard(src *installSource) bool {
return wizardSource(src) && !installCfg.Silent && !installCfg.DryRun &&
!installCfg.Update && system.HasTTY()
return wizardSource(src) && wizardMode(installCfg, system.HasTTY())
}

// wizardMode is the mode half of wizard routing. DryRun is intentionally not
// checked: previewing changes changes apply semantics, not the planning UI.
func wizardMode(cfg *config.Config, hasTTY bool) bool {
return !cfg.Silent && !cfg.Update && hasTTY
}

// wizardSource is the source-kind half of the wizard-routing decision, split
Expand All @@ -184,8 +194,8 @@ func wizardSource(src *installSource) bool {
case sourceNone:
return true
case sourcePreset:
// An unknown preset must keep the linear path, which rejects it with a
// clear error instead of silently opening an empty wizard.
// Keep this check defensive even though applyInstallSource validates the
// flag before routing.
_, ok := config.GetPreset(installCfg.Preset)
return ok
default:
Expand Down Expand Up @@ -236,7 +246,9 @@ func runConfigWizard(ctx context.Context, rc *config.RemoteConfig) error {
if err := applyReviewedPlan(ctx, plan); err != nil {
return err
}
saveSyncSourceIfRemote(installCfg)
if !installCfg.DryRun {
saveSyncSourceIfRemote(installCfg)
}
return nil
}

Expand Down Expand Up @@ -356,6 +368,9 @@ func applyInstallSource(src *installSource) error {

case sourcePreset:
// installCfg.Preset is already set (by flag or resolvePositionalArg).
if _, ok := config.GetPreset(installCfg.Preset); !ok {
return fmt.Errorf("unknown preset %q (available: %s)", installCfg.Preset, strings.Join(config.GetPresetNames(), ", "))
}
return nil

case sourceSyncSource:
Expand Down
37 changes: 35 additions & 2 deletions internal/cli/wizard_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/openbootdotdev/openboot/internal/config"
)

// wizardSource is the source-kind half of shouldLaunchWizard, split out so the
// routing is testable without a TTY: bare installs and valid presets get the
// full wizard; unknown presets and remote sources keep the linear path.
// full wizard; unknown presets and remote sources use their dedicated paths.
func TestWizardSource(t *testing.T) {
oldPreset := installCfg.Preset
defer func() { installCfg.Preset = oldPreset }()
Expand All @@ -19,9 +21,40 @@ func TestWizardSource(t *testing.T) {
assert.True(t, wizardSource(&installSource{kind: sourcePreset}), "valid preset")

installCfg.Preset = "not-a-preset"
assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset keeps the linear error path")
assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset is rejected before wizard launch")

assert.False(t, wizardSource(&installSource{kind: sourceCloud}), "cloud config")
assert.False(t, wizardSource(&installSource{kind: sourceSyncSource}), "sync source")
assert.False(t, wizardSource(&installSource{kind: sourceFile}), "local file")
}

func TestWizardMode(t *testing.T) {
tests := []struct {
name string
cfg config.Config
hasTTY bool
want bool
}{
{name: "interactive install", hasTTY: true, want: true},
{name: "dry run uses the same mode", cfg: config.Config{InstallOptions: config.InstallOptions{DryRun: true}}, hasTTY: true, want: true},
{name: "silent", cfg: config.Config{InstallOptions: config.InstallOptions{Silent: true}}, hasTTY: true},
{name: "update", cfg: config.Config{InstallOptions: config.InstallOptions{Update: true}}, hasTTY: true},
{name: "non tty"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, wizardMode(&tt.cfg, tt.hasTTY))
})
}
}

func TestApplyInstallSourceRejectsUnknownPreset(t *testing.T) {
oldPreset := installCfg.Preset
defer func() { installCfg.Preset = oldPreset }()

installCfg.Preset = "not-a-preset"
err := applyInstallSource(&installSource{kind: sourcePreset})

assert.EqualError(t, err, `unknown preset "not-a-preset" (available: minimal, developer, full)`)
}
2 changes: 1 addition & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type InstallOptions struct {
// 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
SelectedPkgs map[string]bool // set by the wizard or preset planner
OnlinePkgs []Package // fetched from packages API
SnapshotTaps []string // from snapshot capture
RemoteConfig *RemoteConfig // fetched from openboot.dev at startup
Expand Down
6 changes: 4 additions & 2 deletions internal/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import (
"github.com/openbootdotdev/openboot/internal/ui"
)

var ErrUserCancelled = errors.New("user cancelled")

const (
estimatedSecondsPerFormula = 15
estimatedSecondsPerCask = 30
Expand Down Expand Up @@ -104,6 +102,10 @@ func ApplyReviewedPlan(ctx context.Context, plan InstallPlan) error {
ui.Println()
ui.Header(fmt.Sprintf("OpenBoot Installer v%s", plan.Version))
ui.Println()
if plan.DryRun {
ui.Muted("[DRY-RUN MODE - No changes will be made]")
ui.Println()
}
return ApplyContext(ctx, plan, ConsoleReporter{})
}

Expand Down
5 changes: 0 additions & 5 deletions internal/installer/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,6 @@ func TestGetStatePath(t *testing.T) {
assert.True(t, filepath.IsAbs(path))
}

func TestErrUserCancelled(t *testing.T) {
assert.Error(t, ErrUserCancelled)
assert.Equal(t, "user cancelled", ErrUserCancelled.Error())
}

func TestInstallState_OnlySuccessfulPackagesMarked(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
Expand Down
39 changes: 5 additions & 34 deletions internal/installer/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/openbootdotdev/openboot/internal/shell"
"github.com/openbootdotdev/openboot/internal/system"
"github.com/openbootdotdev/openboot/internal/ui"
"github.com/openbootdotdev/openboot/internal/ui/tui"
)

// InstallPlan captures all resolved decisions from the interactive planning phase.
Expand Down Expand Up @@ -204,30 +203,12 @@ func planGitConfig(opts *config.InstallOptions) (name, email string, err error)

func planPackages(opts *config.InstallOptions, st *config.InstallState, plan *InstallPlan) error {
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 fmt.Errorf("select preset: %w", err)
}
}
opts.Preset = "minimal"
}

if opts.Silent || (opts.DryRun && !system.HasTTY()) {
st.SelectedPkgs = config.GetPackagesForPreset(opts.Preset)
} else {
selected, onlinePkgs, confirmed, err := tui.RunSelector(opts.Preset)
if err != nil {
return fmt.Errorf("run package selector: %w", err)
}
if !confirmed {
return ErrUserCancelled
}
st.SelectedPkgs = selected
st.OnlinePkgs = onlinePkgs
if _, ok := config.GetPreset(opts.Preset); !ok {
return fmt.Errorf("unknown preset %q", opts.Preset)
}
st.SelectedPkgs = config.GetPackagesForPreset(opts.Preset)

plan.SelectedPkgs = st.SelectedPkgs
plan.OnlinePkgs = st.OnlinePkgs
Expand Down Expand Up @@ -295,17 +276,7 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error)
if opts.Macos == "skip" {
return nil, nil
}
if opts.Macos == "configure" || opts.Silent || (opts.DryRun && !system.HasTTY()) {
return macos.DefaultPreferences, nil
}
selected, confirmed, err := tui.RunMacOSSelector()
if err != nil {
return nil, fmt.Errorf("macOS selector: %w", err)
}
if !confirmed {
return nil, nil
}
return selected, nil
return macos.DefaultPreferences, nil
}

// PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit
Expand Down
11 changes: 11 additions & 0 deletions internal/installer/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ func TestPlanInteractive_DryRun_Minimal_AllSkipped(t *testing.T) {
assert.False(t, plan.InstallOhMyZsh)
}

func TestPlanPackages_UnknownPresetRejected(t *testing.T) {
opts := &config.InstallOptions{Preset: "not-a-preset"}
st := &config.InstallState{}
plan := InstallPlan{}

err := planPackages(opts, st, &plan)
require.Error(t, err)
assert.Contains(t, err.Error(), `unknown preset "not-a-preset"`)
assert.Nil(t, st.SelectedPkgs)
}

// ---------------------------------------------------------------------------
// PlanFromSnapshot
// ---------------------------------------------------------------------------
Expand Down
18 changes: 1 addition & 17 deletions internal/macos/categories.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
package macos

// PrefCategory groups related macOS preferences for display in the TUI selector.
// PrefCategory groups related macOS preferences in the default catalog.
type PrefCategory struct {
Name string
Icon string
Prefs []Preference
}

// PrefKey returns a unique identifier for a preference, used as the selection map key.
func PrefKey(p Preference) string {
return p.Domain + "/" + p.Key
}

// DefaultCategories groups DefaultPreferences by logical category.
var DefaultCategories = []PrefCategory{
{
Expand Down Expand Up @@ -156,14 +151,3 @@ var DefaultCategories = []PrefCategory{
},
},
}

// AllPrefsSelected returns a map with all default preferences set to true.
func AllPrefsSelected() map[string]bool {
selected := make(map[string]bool)
for _, cat := range DefaultCategories {
for _, p := range cat.Prefs {
selected[PrefKey(p)] = true
}
}
return selected
}
Loading
Loading