From 5fa8bc23630e15431ac84c12c36fc5ef36eb3739 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:16:14 +0800 Subject: [PATCH 01/12] feat(progress): stream install progress via an optional sink Add internal/progress, a leaf package defining the streaming event contract (Event + Sink) between the install engine and a live renderer. brew and npm gain SetProgressSink: when a sink is registered they emit structured StepStart/StepOK/StepFail events (with the command and phase) instead of drawing their own ui.StickyProgress bar, letting a caller own the terminal. With no sink the console behavior is byte-for-byte unchanged. The two exec.Command baseline entries shifted by the added import lines; baseline regenerated accordingly. This is the plumbing the install TUI needs to render a live pipeline. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/archtest/baseline/no-direct-exec.txt | 4 +- internal/brew/brew_install.go | 41 +++++++++------ internal/brew/streaming.go | 52 +++++++++++++++++++ internal/brew/streaming_test.go | 43 +++++++++++++++ internal/npm/npm.go | 38 ++++++++++---- internal/npm/streaming.go | 50 ++++++++++++++++++ internal/npm/streaming_test.go | 37 +++++++++++++ internal/progress/progress.go | 50 ++++++++++++++++++ internal/progress/progress_test.go | 19 +++++++ 9 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 internal/brew/streaming.go create mode 100644 internal/brew/streaming_test.go create mode 100644 internal/npm/streaming.go create mode 100644 internal/npm/streaming_test.go create mode 100644 internal/progress/progress.go create mode 100644 internal/progress/progress_test.go diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 7de4d42..60ae41f 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -2,7 +2,7 @@ # Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... internal/auth/login.go:195 -internal/brew/brew_install.go:327 +internal/brew/brew_install.go:334 internal/cli/snapshot.go:22 internal/diff/compare.go:247 internal/diff/compare.go:253 @@ -12,7 +12,7 @@ internal/dotfiles/dotfiles.go:66 internal/dotfiles/dotfiles.go:351 internal/dotfiles/dotfiles.go:449 internal/installer/step_system.go:132 -internal/npm/npm.go:22 +internal/npm/npm.go:23 internal/permissions/screen_recording_cgo.go:21 internal/shell/shell.go:184 internal/updater/updater.go:205 diff --git a/internal/brew/brew_install.go b/internal/brew/brew_install.go index 6a3c5d8..e304b48 100644 --- a/internal/brew/brew_install.go +++ b/internal/brew/brew_install.go @@ -9,6 +9,7 @@ import ( "strings" "time" + progresspkg "github.com/openbootdotdev/openboot/internal/progress" "github.com/openbootdotdev/openboot/internal/system" "github.com/openbootdotdev/openboot/internal/ui" ) @@ -142,14 +143,19 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun return installedFormulae, installedCasks, preErr } - progress := ui.NewStickyProgress(len(newCli) + len(newCask)) - progress.SetSkipped(skipped) - progress.Start() + // bar stays nil when a streaming sink is registered — the sink owns the + // terminal, so we emit events instead of drawing a sticky progress bar. + var bar *ui.StickyProgress + if !streaming() { + bar = ui.NewStickyProgress(len(newCli) + len(newCask)) + bar.SetSkipped(skipped) + bar.Start() + } var allFailed []failedJob if len(newCli) > 0 { - failed := runSerialInstallWithProgress(ctx, newCli, progress) + failed := runSerialInstallWithProgress(ctx, newCli, bar) failedSet := make(map[string]bool, len(failed)) for _, f := range failed { failedSet[f.name] = true @@ -163,12 +169,14 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun } if len(newCask) > 0 { - caskInstalled, caskFailed := installCasksWithProgress(ctx, newCask, progress) + caskInstalled, caskFailed := installCasksWithProgress(ctx, newCask, bar) installedCasks = append(installedCasks, caskInstalled...) allFailed = append(allFailed, caskFailed...) } - progress.Finish() + if bar != nil { + bar.Finish() + } allFailed = retryFailedJobs(ctx, allFailed, &installedFormulae, &installedCasks, aliasMap) @@ -186,22 +194,21 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun } // installCasksWithProgress installs cask packages one by one with brew output -// suppressed. Returns successful installs and failed jobs. -func installCasksWithProgress(ctx context.Context, pkgs []string, progress *ui.StickyProgress) (installed []string, failed []failedJob) { +// suppressed. Returns successful installs and failed jobs. bar is nil when a +// streaming progress sink is registered. +func installCasksWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) (installed []string, failed []failedJob) { for _, pkg := range pkgs { - progress.SetCurrent(pkg) + stepStart(bar, progresspkg.PhaseApplications, pkg, "brew install --cask "+pkg) start := time.Now() errMsg := installCaskWithProgress(ctx, pkg) elapsed := time.Since(start) - progress.IncrementWithStatus(errMsg == "") duration := ui.FormatDuration(elapsed) + stepDone(bar, progresspkg.PhaseApplications, pkg, errMsg == "", errMsg, duration) if errMsg == "" { - progress.PrintLine(" %s %s", ui.Green("✔ "+pkg), ui.Cyan("("+duration+")")) installed = append(installed, pkg) } else { - progress.PrintLine(" %s %s", ui.Red("✗ "+pkg+" ("+errMsg+")"), ui.Cyan("("+duration+")")) failed = append(failed, failedJob{ installJob: installJob{name: pkg, isCask: true}, errMsg: errMsg, @@ -278,7 +285,9 @@ func handleFailedJobs(failed []failedJob) { } } -func runSerialInstallWithProgress(ctx context.Context, pkgs []string, progress *ui.StickyProgress) []failedJob { +// runSerialInstallWithProgress installs formulae one by one. bar is nil when a +// streaming progress sink is registered. +func runSerialInstallWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) []failedJob { if len(pkgs) == 0 { return nil } @@ -286,20 +295,18 @@ func runSerialInstallWithProgress(ctx context.Context, pkgs []string, progress * failed := make([]failedJob, 0) for _, pkg := range pkgs { job := installJob{name: pkg, isCask: false} - progress.SetCurrent(job.name) + stepStart(bar, progresspkg.PhaseHomebrew, job.name, "brew install "+job.name) start := time.Now() errMsg := installFormulaWithError(ctx, job.name) elapsed := time.Since(start) - progress.IncrementWithStatus(errMsg == "") duration := ui.FormatDuration(elapsed) + stepDone(bar, progresspkg.PhaseHomebrew, job.name, errMsg == "", errMsg, duration) if errMsg == "" { - progress.PrintLine(" %s %s", ui.Green("✔ "+job.name), ui.Cyan("("+duration+")")) continue } - progress.PrintLine(" %s %s", ui.Red("✗ "+job.name+" ("+errMsg+")"), ui.Cyan("("+duration+")")) failed = append(failed, failedJob{ installJob: job, errMsg: errMsg, diff --git a/internal/brew/streaming.go b/internal/brew/streaming.go new file mode 100644 index 0000000..b6c02e3 --- /dev/null +++ b/internal/brew/streaming.go @@ -0,0 +1,52 @@ +package brew + +import ( + "github.com/openbootdotdev/openboot/internal/progress" + "github.com/openbootdotdev/openboot/internal/ui" +) + +// progressSink, when non-nil, makes InstallWithProgress stream structured +// progress.Events instead of drawing a ui.StickyProgress bar. The install TUI +// registers a sink so it can own the terminal; the default console flow leaves +// it nil. Mirrors the SetRunner swap-and-restore pattern. +var progressSink progress.Sink + +// SetProgressSink registers a streaming progress sink and returns a restore +// func that clears it. +func SetProgressSink(s progress.Sink) (restore func()) { + prev := progressSink + progressSink = s + return func() { progressSink = prev } +} + +// streaming reports whether a sink is registered (TUI mode). +func streaming() bool { return progressSink != nil } + +// stepStart reports the beginning of a package install: emits a StepStart event +// when streaming, otherwise advances the sticky progress bar. +func stepStart(bar *ui.StickyProgress, phase, name, command string) { + if streaming() { + progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepStart, Command: command}) + return + } + bar.SetCurrent(name) +} + +// stepDone reports the result of a package install, preserving the exact +// console output when not streaming. +func stepDone(bar *ui.StickyProgress, phase, name string, ok bool, errMsg, duration string) { + if streaming() { + if ok { + progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepOK, Detail: duration}) + } else { + progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepFail, Detail: errMsg}) + } + return + } + bar.IncrementWithStatus(ok) + if ok { + bar.PrintLine(" %s %s", ui.Green("✔ "+name), ui.Cyan("("+duration+")")) + } else { + bar.PrintLine(" %s %s", ui.Red("✗ "+name+" ("+errMsg+")"), ui.Cyan("("+duration+")")) + } +} diff --git a/internal/brew/streaming_test.go b/internal/brew/streaming_test.go new file mode 100644 index 0000000..47b8fd3 --- /dev/null +++ b/internal/brew/streaming_test.go @@ -0,0 +1,43 @@ +package brew + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/progress" +) + +func TestSetProgressSinkSwapAndRestore(t *testing.T) { + require.Nil(t, progressSink) + require.False(t, streaming()) + + var got []progress.Event + restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) + require.True(t, streaming()) + + progressSink.Emit(progress.Event{Name: "x"}) + restore() + assert.Nil(t, progressSink) + assert.False(t, streaming()) + assert.Len(t, got, 1) +} + +// When streaming, the step helpers must emit events and never touch the (nil) bar. +func TestStepHelpersEmitWhenStreaming(t *testing.T) { + var got []progress.Event + restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) + defer restore() + + assert.NotPanics(t, func() { + stepStart(nil, progress.PhaseHomebrew, "git", "brew install git") + stepDone(nil, progress.PhaseHomebrew, "git", true, "", "1.2s") + stepDone(nil, progress.PhaseApplications, "figma", false, "download failed", "0.5s") + }) + + require.Len(t, got, 3) + assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepStart, Command: "brew install git"}, got[0]) + assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepOK, Detail: "1.2s"}, got[1]) + assert.Equal(t, progress.Event{Phase: progress.PhaseApplications, Name: "figma", Status: progress.StepFail, Detail: "download failed"}, got[2]) +} diff --git a/internal/npm/npm.go b/internal/npm/npm.go index f9bd246..e571a3a 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/openbootdotdev/openboot/internal/progress" "github.com/openbootdotdev/openboot/internal/ui" ) @@ -178,17 +179,29 @@ func warnIfNodeVersionTooLow(packages []string) { // fails it falls back to sequential per-package installs. Returns the list of // package names that could not be installed and any fatal error. func installBatchContext(ctx context.Context, toInstall []string) (failed []string, err error) { + if streaming() { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Status: progress.StepStart, Command: "npm install -g " + strings.Join(toInstall, " ")}) + } + args := append([]string{"install", "-g"}, toInstall...) batchOutput, batchErr := runnerCombinedOutputContext(ctx, args...) if batchErr == nil { - ui.Success(fmt.Sprintf(" ✔ %d npm packages installed", len(toInstall))) + if streaming() { + for _, p := range toInstall { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: p, Status: progress.StepOK}) + } + } else { + ui.Success(fmt.Sprintf(" ✔ %d npm packages installed", len(toInstall))) + } return nil, nil } batchError := parseNpmError(string(batchOutput)) - ui.Warn(fmt.Sprintf("Batch install failed (%s), falling back to sequential...", batchError)) - ui.Println() + if !streaming() { + ui.Warn(fmt.Sprintf("Batch install failed (%s), falling back to sequential...", batchError)) + ui.Println() + } return installSequentialContext(ctx, toInstall) } @@ -213,22 +226,25 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [ return nil, nil } - progress := ui.NewStickyProgress(len(remaining)) - progress.Start() + // bar stays nil when a streaming sink is registered. + var bar *ui.StickyProgress + if !streaming() { + bar = ui.NewStickyProgress(len(remaining)) + bar.Start() + } for _, pkg := range remaining { - progress.SetCurrent(pkg) + npmStepStart(bar, pkg) errMsg := installNpmPackageWithRetryContext(ctx, pkg) + npmStepDone(bar, pkg, errMsg == "", errMsg) if errMsg != "" { - progress.PrintLine(" ✗ %s (%s)", pkg, errMsg) failed = append(failed, pkg) - } else { - progress.PrintLine(" ✔ %s", pkg) } - progress.Increment() } - progress.Finish() + if bar != nil { + bar.Finish() + } return failed, nil } diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go new file mode 100644 index 0000000..bb11889 --- /dev/null +++ b/internal/npm/streaming.go @@ -0,0 +1,50 @@ +package npm + +import ( + "github.com/openbootdotdev/openboot/internal/progress" + "github.com/openbootdotdev/openboot/internal/ui" +) + +// progressSink, when non-nil, makes the npm installer stream structured +// progress.Events instead of drawing a ui.StickyProgress bar. Mirrors brew's +// SetProgressSink swap-and-restore pattern. +var progressSink progress.Sink + +// SetProgressSink registers a streaming progress sink and returns a restore +// func that clears it. +func SetProgressSink(s progress.Sink) (restore func()) { + prev := progressSink + progressSink = s + return func() { progressSink = prev } +} + +// streaming reports whether a sink is registered (TUI mode). +func streaming() bool { return progressSink != nil } + +// npmStepStart reports the start of a single npm package install. +func npmStepStart(bar *ui.StickyProgress, name string) { + if streaming() { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepStart, Command: "npm install -g " + name}) + return + } + bar.SetCurrent(name) +} + +// npmStepDone reports the result of a single npm package install, preserving +// the exact console output when not streaming. +func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg string) { + if streaming() { + if ok { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK}) + } else { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepFail, Detail: errMsg}) + } + return + } + bar.Increment() + if ok { + bar.PrintLine(" ✔ %s", name) + } else { + bar.PrintLine(" ✗ %s (%s)", name, errMsg) + } +} diff --git a/internal/npm/streaming_test.go b/internal/npm/streaming_test.go new file mode 100644 index 0000000..fa73ec7 --- /dev/null +++ b/internal/npm/streaming_test.go @@ -0,0 +1,37 @@ +package npm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/progress" +) + +func TestSetProgressSinkSwapAndRestore(t *testing.T) { + require.Nil(t, progressSink) + require.False(t, streaming()) + + restore := SetProgressSink(func(progress.Event) {}) + require.True(t, streaming()) + restore() + assert.False(t, streaming()) +} + +func TestNpmStepHelpersEmitWhenStreaming(t *testing.T) { + var got []progress.Event + restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) + defer restore() + + assert.NotPanics(t, func() { + npmStepStart(nil, "typescript") + npmStepDone(nil, "typescript", true, "") + npmStepDone(nil, "eslint", false, "E404") + }) + + require.Len(t, got, 3) + assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepStart, Command: "npm install -g typescript"}, got[0]) + assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepOK}, got[1]) + assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "eslint", Status: progress.StepFail, Detail: "E404"}, got[2]) +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..67e86ed --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,50 @@ +// Package progress defines the streaming progress event contract between the +// install engine (brew, npm) and a live renderer such as the install TUI. +// +// It is a leaf package with no internal dependencies so brew, npm, installer, +// and the TUI can all import it without creating an import cycle. +// +// When no Sink is registered the install engine renders its own progress +// (ui.StickyProgress). When a Sink is registered the engine emits Events +// instead and draws nothing to stdout, letting the caller own the display. +package progress + +// Status describes where a step is in its lifecycle. +type Status int + +const ( + // StepStart is emitted when a step begins (before the command runs). + StepStart Status = iota + // StepOK is emitted when a step finishes successfully. + StepOK + // StepFail is emitted when a step fails. + StepFail +) + +// Event is a single progress signal emitted during installation. +type Event struct { + Phase string // pipeline phase, e.g. "Homebrew", "Applications", "npm globals" + Name string // step name, e.g. the package being installed + Status Status + Command string // shell command being run, for the live log (StepStart only) + Detail string // result detail: version/duration on success, error message on failure +} + +// Canonical phase names emitted by the install engine. The TUI matches on +// these so brew, npm, and the renderer stay in agreement. +const ( + PhaseHomebrew = "Homebrew" + PhaseApplications = "Applications" + PhaseNpm = "npm globals" +) + +// Sink receives install progress events. A nil Sink means "no streaming +// renderer registered" — the engine falls back to its own progress output. +type Sink func(Event) + +// Emit sends an event to s, tolerating a nil sink. +func (s Sink) Emit(ev Event) { + if s != nil { + s(ev) + } +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..86788ce --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,19 @@ +package progress + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSinkEmitNilSafe(t *testing.T) { + var s Sink // nil + assert.NotPanics(t, func() { s.Emit(Event{Name: "x"}) }) +} + +func TestSinkEmitForwards(t *testing.T) { + var got []Event + s := Sink(func(e Event) { got = append(got, e) }) + s.Emit(Event{Phase: PhaseHomebrew, Name: "git", Status: StepOK, Detail: "1s"}) + assert.Equal(t, []Event{{Phase: PhaseHomebrew, Name: "git", Status: StepOK, Detail: "1s"}}, got) +} From 654bc66aad6c66e9867c7951a007b34e538d743c Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:16:22 +0800 Subject: [PATCH 02/12] feat(installer): add PlanFromSelection for TUI-driven installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit package selection without any interactive prompts — it categorizes the selection and fills the system-config defaults (existing git identity, oh-my-zsh, dotfiles, macOS prefs), honoring --packages-only and the --shell/--macos/--dotfiles skip flags. Git identity is reused from the existing global config when present and skipped otherwise, since the TUI has no name/email screen. This is the non-prompting counterpart to planInteractive, used by the install wizard. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/installer/plan.go | 58 +++++++++++++++++++++++ internal/installer/plan_selection_test.go | 54 +++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/installer/plan_selection_test.go diff --git a/internal/installer/plan.go b/internal/installer/plan.go index d772007..d291f3f 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -308,6 +308,64 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) return selected, nil } +// PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit +// package selection gathered by the install TUI. It applies system-config +// defaults (existing git identity, oh-my-zsh, dotfiles, macOS prefs) without +// any interactive prompts — all interaction already happened in the wizard. +// +// Git identity is reused from the existing global git config when present; when +// absent the git step is skipped rather than prompting, since the TUI has no +// name/email screen. CLI overrides (--packages-only, --shell/--macos/--dotfiles +// skip) are still honored via opts. +func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) InstallPlan { + st := &config.InstallState{SelectedPkgs: selected} + + plan := InstallPlan{ + Version: opts.Version, + DryRun: opts.DryRun, + Silent: opts.Silent, + PackagesOnly: opts.PackagesOnly, + AllowPostInstall: opts.AllowPostInstall, + SelectedPkgs: selected, + } + + cats := categorizeSelectedPackages(opts, st) + plan.Formulae = cats.cli + plan.Casks = cats.cask + plan.Npm = cats.npm + + if opts.PackagesOnly { + return plan + } + + name, email := system.GetExistingGitConfig() + if name != "" && email != "" { + plan.GitName = name + plan.GitEmail = email + } else { + plan.SkipGit = true + } + + plan.InstallOhMyZsh = opts.Shell != "skip" + + if opts.Dotfiles != "skip" { + url := dotfiles.GetDotfilesURL() + if url == "" { + url = opts.DotfilesURL + } + if url == "" { + url = dotfiles.DefaultDotfilesURL + } + plan.DotfilesURL = url + } + + if opts.Macos != "skip" { + plan.MacOSPrefs = macos.DefaultPreferences + } + + return plan +} + // PlanFromSnapshot builds an InstallPlan from snapshot state without any interactive // prompts. All decisions are derived from st.Snapshot* fields and opts. func PlanFromSnapshot(opts *config.InstallOptions, st *config.InstallState) InstallPlan { diff --git a/internal/installer/plan_selection_test.go b/internal/installer/plan_selection_test.go new file mode 100644 index 0000000..cd1ac6f --- /dev/null +++ b/internal/installer/plan_selection_test.go @@ -0,0 +1,54 @@ +package installer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/config" +) + +func TestPlanFromSelectionDefaults(t *testing.T) { + opts := &config.InstallOptions{Version: "1.0.0"} + sel := config.GetPackagesForPreset("developer") + require.NotEmpty(t, sel) + + plan := PlanFromSelection(opts, sel) + + assert.Equal(t, "1.0.0", plan.Version) + assert.Positive(t, len(plan.Formulae)+len(plan.Casks)+len(plan.Npm), "packages categorized") + assert.True(t, plan.InstallOhMyZsh, "shell installed by default") + assert.NotEmpty(t, plan.DotfilesURL, "dotfiles default applied") + assert.NotEmpty(t, plan.MacOSPrefs, "macOS prefs default applied") + + // Git identity: either reused from existing config or explicitly skipped — + // never a half-filled state (the TUI never prompts). + if plan.SkipGit { + assert.Empty(t, plan.GitName) + assert.Empty(t, plan.GitEmail) + } else { + assert.NotEmpty(t, plan.GitName) + assert.NotEmpty(t, plan.GitEmail) + } +} + +func TestPlanFromSelectionPackagesOnly(t *testing.T) { + opts := &config.InstallOptions{PackagesOnly: true} + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + + assert.True(t, plan.PackagesOnly) + assert.False(t, plan.InstallOhMyZsh) + assert.Empty(t, plan.DotfilesURL) + assert.Empty(t, plan.MacOSPrefs) + assert.Positive(t, len(plan.Formulae)+len(plan.Casks), "packages still categorized") +} + +func TestPlanFromSelectionSkipFlags(t *testing.T) { + opts := &config.InstallOptions{Shell: "skip", Dotfiles: "skip", Macos: "skip"} + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + + assert.False(t, plan.InstallOhMyZsh) + assert.Empty(t, plan.DotfilesURL) + assert.Empty(t, plan.MacOSPrefs) +} From 39f28dc5b50631f13da3812a5e4f8e196539f2ad Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:16:32 +0800 Subject: [PATCH 03/12] feat(tui): implement v5 install wizard and make it the interactive flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/ui/tui/wizard: a single full-screen bubbletea program that flows boot-probe → two-pane select → live pipeline install, under a persistent title bar and status bar — the OpenBoot TUI Redesign v5. - boot: animated real probes (hardware, Homebrew health, installed-tool scan, catalog) then a loadout picker backed by the built-in presets. - select: two-pane catalog with per-category counts, substring filter, keyboard + mouse-free selection; already-installed packages are dimmed and non-toggleable. - install: pipeline sidebar with per-phase progress, a live $cmd/✓result log, and a progress bar — driven by real events streamed from the install engine (brew/npm sinks + a Reporter bridged onto a channel). The Apply run is forced non-interactive so no prompt fires mid-screen; stray engine stdout is redirected away from the alt-screen. Bare `openboot install` on a TTY now launches the wizard. Explicit sources (-p, --from, -u, sync), --silent, and --dry-run keep their existing flows untouched. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/cli/install.go | 20 ++ internal/ui/tui/wizard/boot.go | 306 +++++++++++++++++++ internal/ui/tui/wizard/frames_test.go | 111 +++++++ internal/ui/tui/wizard/install.go | 424 ++++++++++++++++++++++++++ internal/ui/tui/wizard/select.go | 354 +++++++++++++++++++++ internal/ui/tui/wizard/status.go | 32 ++ internal/ui/tui/wizard/styles.go | 39 +++ internal/ui/tui/wizard/wizard.go | 286 +++++++++++++++++ internal/ui/tui/wizard/wizard_test.go | 229 ++++++++++++++ 9 files changed, 1801 insertions(+) create mode 100644 internal/ui/tui/wizard/boot.go create mode 100644 internal/ui/tui/wizard/frames_test.go create mode 100644 internal/ui/tui/wizard/install.go create mode 100644 internal/ui/tui/wizard/select.go create mode 100644 internal/ui/tui/wizard/status.go create mode 100644 internal/ui/tui/wizard/styles.go create mode 100644 internal/ui/tui/wizard/wizard.go create mode 100644 internal/ui/tui/wizard/wizard_test.go diff --git a/internal/cli/install.go b/internal/cli/install.go index 96a7fe9..976d9f3 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -17,6 +17,7 @@ import ( "github.com/openbootdotdev/openboot/internal/system" "github.com/openbootdotdev/openboot/internal/ui" "github.com/openbootdotdev/openboot/internal/ui/tui" + "github.com/openbootdotdev/openboot/internal/ui/tui/wizard" ) // installCfg is the config instance used by the install subcommand. @@ -116,6 +117,14 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { if err := applyInstallSource(src); err != nil { return fmt.Errorf("apply install source: %w", err) } + + // Bare interactive `openboot install` on a TTY runs the full-screen + // install wizard (boot probe → select → live install). Explicit + // sources (-p, --from, -u, sync), --silent, and --dry-run keep their + // existing flows. + if src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && system.HasTTY() { + return runInstallWizard() + } } pickRaw, _ := cmd.Flags().GetString("pick") @@ -151,6 +160,17 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { return err } +// runInstallWizard launches the full-screen install TUI and runs the resulting +// install. The wizard owns the whole interactive flow (planning + apply), so +// there is nothing further to do here on success. +func runInstallWizard() error { + opts := installCfg.ToInstallOptions() + if _, err := wizard.Run(installCfg.Version, opts); err != nil { + return fmt.Errorf("install wizard: %w", err) + } + return nil +} + // ── Source resolution ───────────────────────────────────────────────────────── type sourceKind int diff --git a/internal/ui/tui/wizard/boot.go b/internal/ui/tui/wizard/boot.go new file mode 100644 index 0000000..0c23d18 --- /dev/null +++ b/internal/ui/tui/wizard/boot.go @@ -0,0 +1,306 @@ +package wizard + +import ( + "fmt" + "runtime" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/openbootdotdev/openboot/internal/brew" + "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/macos" + "github.com/openbootdotdev/openboot/internal/npm" + "github.com/openbootdotdev/openboot/internal/system" +) + +// probeRow is one line of the boot-time environment probe. +type probeRow struct { + key string // short label, e.g. "probe", "brew", "scan", "sync" + busy string // shown with a spinner while the probe runs + result string // shown with a ✓ once done + ok bool + done bool +} + +func newProbes() []probeRow { + return []probeRow{ + {key: "probe", busy: "reading hardware profile…"}, + {key: "brew", busy: "checking Homebrew…"}, + {key: "scan", busy: "scanning /opt/homebrew, /Applications…"}, + {key: "sync", busy: "syncing catalog…"}, + } +} + +// loadout is a starting-point preset offered on the boot screen. +type loadout struct { + key string + name string + preset string // backing preset name in config +} + +func newLoadouts() []loadout { + return []loadout{ + {key: "1", name: "Minimal", preset: "minimal"}, + {key: "2", name: "Developer", preset: "developer"}, + {key: "3", name: "Full", preset: "full"}, + } +} + +// probeDoneMsg reports the completion of probe idx. +type probeDoneMsg struct { + idx int + result string + ok bool + installed map[string]bool // non-nil only for the scan probe +} + +// runProbe returns a command that executes probe i off the UI goroutine. +func (m Model) runProbe(i int) tea.Cmd { + if i >= len(m.probes) { + return nil + } + cats := m.cats + return func() tea.Msg { + switch i { + case 0: + return probeDoneMsg{idx: i, result: hardwareProfile(), ok: true} + case 1: + res, ok := brewHealth() + return probeDoneMsg{idx: i, result: res, ok: ok} + case 2: + inst := scanInstalled(cats) + word := "tools" + if len(inst) == 1 { + word = "tool" + } + return probeDoneMsg{ + idx: i, + result: fmt.Sprintf("%d %s already on this Mac — will be skipped", len(inst), word), + ok: true, + installed: inst, + } + default: + return probeDoneMsg{idx: i, result: catalogSummary(cats), ok: true} + } + } +} + +func (m Model) onProbeDone(msg probeDoneMsg) (tea.Model, tea.Cmd) { + if msg.idx >= 0 && msg.idx < len(m.probes) { + m.probes[msg.idx].result = msg.result + m.probes[msg.idx].ok = msg.ok + m.probes[msg.idx].done = true + } + if msg.installed != nil { + m.installed = msg.installed + } + m.probeIdx = msg.idx + 1 + if m.probeIdx < len(m.probes) { + return m, m.runProbe(m.probeIdx) + } + return m, nil // probing complete; wait for a loadout choice +} + +// ── probe implementations (read-only; go through system/brew/npm) ── + +func hardwareProfile() string { + var parts []string + if ver, err := system.RunCommandSilent("sw_vers", "-productVersion"); err == nil { + if v := strings.TrimSpace(ver); v != "" { + parts = append(parts, "macOS "+v) + } + } + if chip, err := system.RunCommandSilent("sysctl", "-n", "machdep.cpu.brand_string"); err == nil { + if c := strings.TrimSpace(chip); c != "" { + parts = append(parts, c) + } + } + if memStr, err := system.RunCommandSilent("sysctl", "-n", "hw.memsize"); err == nil { + if b, perr := strconv.ParseInt(strings.TrimSpace(memStr), 10, 64); perr == nil && b > 0 { + parts = append(parts, fmt.Sprintf("%d GB", b/(1024*1024*1024))) + } + } + parts = append(parts, runtime.GOARCH) + return strings.Join(parts, " · ") +} + +func brewHealth() (string, bool) { + if !brew.IsInstalled() { + return "Homebrew not found — packages will be skipped", false + } + if out, err := system.RunCommandSilent("brew", "--version"); err == nil { + line := strings.SplitN(strings.TrimSpace(out), "\n", 2)[0] + if v := strings.TrimSpace(strings.TrimPrefix(line, "Homebrew")); v != "" { + return "Homebrew " + v + " — healthy", true + } + } + return "Homebrew — healthy", true +} + +// scanInstalled returns the set of catalog package names already present on the +// system (brew formulae, casks, and npm globals). +func scanInstalled(cats []config.Category) map[string]bool { + installed := map[string]bool{} + formulae, casks, _ := brew.GetInstalledPackages() + npmPkgs, _ := npm.GetInstalledPackages() + for _, cat := range cats { + for _, p := range cat.Packages { + switch { + case p.IsNpm: + if npmPkgs[p.Name] { + installed[p.Name] = true + } + case p.IsCask: + if casks[p.Name] { + installed[p.Name] = true + } + default: + if formulae[p.Name] { + installed[p.Name] = true + } + } + } + } + return installed +} + +func catalogSummary(cats []config.Category) string { + n := 0 + for _, c := range cats { + n += len(c.Packages) + } + return fmt.Sprintf("catalog ready · %d packages · %d macOS prefs", n, len(macos.DefaultPreferences)) +} + +// ── boot: key handling ── + +func (m Model) updateBoot(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.probeIdx < len(m.probes) { + return m, nil // still probing — ignore input + } + switch msg.String() { + case "q": + m.quit = true + return m, tea.Quit + case "up", "k": + if m.loadCur > 0 { + m.loadCur-- + } + case "down", "j": + if m.loadCur < len(m.loadouts)-1 { + m.loadCur++ + } + case "1", "2", "3": + i := int(msg.String()[0] - '1') + if i >= 0 && i < len(m.loadouts) { + return m.pickLoadout(i) + } + case "c": + return m.enterSelect(map[string]bool{}) + case "enter": + return m.pickLoadout(m.loadCur) + } + return m, nil +} + +func (m Model) pickLoadout(i int) (tea.Model, tea.Cmd) { + return m.enterSelect(config.GetPackagesForPreset(m.loadouts[i].preset)) +} + +func (m Model) enterSelect(sel map[string]bool) (tea.Model, tea.Cmd) { + m.selected = sel + m.screen = scrSelect + m.catCur, m.rowCur, m.scroll = 0, 0, 0 + m.query, m.typing = "", false + return m, nil +} + +// ── boot: rendering ── + +func (m Model) bootBody(w, _ int) string { + const pad = " " + var b []string + b = append(b, "") + b = append(b, pad+wordmark()+" "+fg(cAccent).Render("▌")) + b = append(b, pad+fg(cDim3).Render("zero → dev-ready, in one command")) + b = append(b, "") + + upto := m.probeIdx + if upto >= len(m.probes) { + upto = len(m.probes) - 1 + } + for i := 0; i <= upto && i < len(m.probes); i++ { + p := m.probes[i] + mark := fg(cAccent).Render("✓") + text := fg(cMuted).Render(p.result) + if !p.done { + mark = fg(cInfo).Render(m.spinner()) + text = fg(cDim2).Render(p.busy) + } else if !p.ok { + mark = fg(cWarn).Render("!") + text = fg(cWarn).Render(p.result) + } + b = append(b, pad+mark+" "+fg(cDim2).Render(padTo(p.key, 8))+text) + } + b = append(b, "") + + if m.probeIdx >= len(m.probes) { + b = append(b, pad+fg(cMuted3).Render("Choose a starting point — or press ")+ + fg(cAccent).Render("c")+fg(cMuted3).Render(" to hand-pick:")) + b = append(b, "") + for i, l := range m.loadouts { + b = append(b, pad+m.renderLoadout(i, l, w-2*len(pad))) + } + b = append(b, "") + b = append(b, pad+fg(cFaint).Render("Every run also restores ")+ + fg(cDim).Render("git identity · zsh + oh-my-zsh · dotfiles · macOS prefs")+ + fg(cFaint).Render(" — not just packages.")) + } + return strings.Join(b, "\n") +} + +func (m Model) renderLoadout(i int, l loadout, w int) string { + preset, _ := config.GetPreset(l.preset) + count := len(config.GetPackagesForPreset(l.preset)) + selected := i == m.loadCur + + cursor := " " + keyBox := fg(cMuted3).Render("[" + l.key + "]") + name := fg(cText).Render(padTo(l.name, 11)) + if selected { + cursor = fg(cAccent).Render("▸ ") + keyBox = fg(cAccent).Render("[" + l.key + "]") + name = fg(cTextHi).Bold(true).Render(padTo(l.name, 11)) + } + + left := cursor + keyBox + " " + name + " " + fg(cDim).Render(preset.Description) + meta := fg(cMuted2).Render(fmt.Sprintf("%d pkgs", count)) + " " + + fg(cAccentHi).Render(fmt.Sprintf("~%d min", estMinutes(count))) + return bar(left, meta, w) +} + +// wordmark renders "openboot" with the design's green gradient. +func wordmark() string { + letters := []struct{ ch, hex string }{ + {"o", "#166534"}, {"p", "#15803d"}, {"e", "#16a34a"}, {"n", "#22c55e"}, + {"b", "#22c55e"}, {"o", "#4ade80"}, {"o", "#86efac"}, {"t", "#bbf7d0"}, + } + var sb strings.Builder + for _, l := range letters { + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(l.hex)).Bold(true).Render(l.ch)) + } + return sb.String() +} + +// estMinutes is a rough install-time estimate (~0.4 min/pkg), matching the +// design's back-of-envelope figure. +func estMinutes(pkgCount int) int { + m := (pkgCount*2 + 4) / 5 // ~0.4 * count, rounded + if m < 1 { + return 1 + } + return m +} diff --git a/internal/ui/tui/wizard/frames_test.go b/internal/ui/tui/wizard/frames_test.go new file mode 100644 index 0000000..3704e5a --- /dev/null +++ b/internal/ui/tui/wizard/frames_test.go @@ -0,0 +1,111 @@ +package wizard + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/installer" + "github.com/openbootdotdev/openboot/internal/macos" + "github.com/openbootdotdev/openboot/internal/progress" +) + +// send routes a message through Update and returns the resulting Model. +func send(m Model, msg tea.Msg) Model { + next, _ := m.Update(msg) + return next.(Model) +} + +func key(s string) tea.KeyMsg { + if len(s) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "space": + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +// finishProbes drives the boot probes to completion with representative data. +func finishProbes(m Model) Model { + m = send(m, probeDoneMsg{idx: 0, result: "macOS 15.5 · Apple M3 Pro · 18 GB · arm64", ok: true}) + m = send(m, probeDoneMsg{idx: 1, result: "Homebrew 4.5.11 — healthy", ok: true}) + m = send(m, probeDoneMsg{idx: 2, result: "6 tools already on this Mac — will be skipped", ok: true, + installed: map[string]bool{"git": true, "python": true}}) + m = send(m, probeDoneMsg{idx: 3, result: "catalog ready · 240 packages · 61 macOS prefs", ok: true}) + return m +} + +// TestDumpFrames renders each screen for manual visual inspection: +// +// go test ./internal/ui/tui/wizard -run TestDumpFrames -v +func TestDumpFrames(t *testing.T) { + const W, H = 96, 30 + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0"}) + m = send(m, tea.WindowSizeMsg{Width: W, Height: H}) + + // Boot: mid-probe (probe 2 running). + mid := send(m, probeDoneMsg{idx: 0, result: "macOS 15.5 · Apple M3 Pro · 18 GB · arm64", ok: true}) + mid = send(mid, probeDoneMsg{idx: 1, result: "Homebrew 4.5.11 — healthy", ok: true}) + t.Log("\n===== BOOT (probing) =====\n" + mid.View()) + + // Boot: done, loadout picker. + m = finishProbes(m) + t.Log("\n===== BOOT (loadouts) =====\n" + m.View()) + + // Select: Developer loadout. + sel := send(m, key("2")) + t.Log("\n===== SELECT (developer) =====\n" + sel.View()) + + // Select: filtered. + f := send(sel, key("/")) + for _, r := range "doc" { + f = send(f, key(string(r))) + } + t.Log("\n===== SELECT (filter 'doc') =====\n" + f.View()) + + // Install: synthetic pipeline (no real Apply). + inst := installFrame(m, W, H) + t.Log("\n===== INSTALL (running) =====\n" + inst.View()) + + done := send(inst, installDoneMsg{}) + t.Log("\n===== INSTALL (done) =====\n" + done.View()) +} + +// installFrame sets up an install-screen model and feeds synthetic progress +// events without running the real install engine. +func installFrame(m Model, _, _ int) Model { + m.screen = scrInstall + m.installing = true + m.plan = installer.InstallPlan{ + Formulae: []string{"node", "go", "ripgrep", "fd", "bat"}, + Casks: []string{"visual-studio-code", "warp"}, + Npm: []string{"typescript", "eslint"}, + MacOSPrefs: make([]macos.Preference, 61), + } + m.plan.InstallOhMyZsh = true + m.plan.DotfilesURL = "https://github.com/x/dotfiles" + m.phases = buildPhases(m.plan) + m.installTick = m.ticks + + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepStart, Command: "brew install node"}}) + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepOK, Detail: "2.1s"}}) + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "go", Status: progress.StepStart, Command: "brew install go"}}) + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "go", Status: progress.StepOK, Detail: "3.4s"}}) + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "ripgrep", Status: progress.StepStart, Command: "brew install ripgrep"}}) + return m +} diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go new file mode 100644 index 0000000..5f05974 --- /dev/null +++ b/internal/ui/tui/wizard/install.go @@ -0,0 +1,424 @@ +package wizard + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/openbootdotdev/openboot/internal/brew" + "github.com/openbootdotdev/openboot/internal/installer" + "github.com/openbootdotdev/openboot/internal/npm" + "github.com/openbootdotdev/openboot/internal/progress" +) + +// phaseState is one row of the install pipeline sidebar. +type phaseState struct { + name string + total int + done int + pkg bool // true for package phases (brew/cask/npm) that count per-item + active bool + finished bool +} + +// logLine is one rendered line of the live install log. +type logLine struct { + mark string + markColor lipgloss.Color + text string + color lipgloss.Color +} + +// reporterKind classifies an installer.Reporter call. +type reporterKind int + +const ( + rHeader reporterKind = iota + rInfo + rSuccess + rWarn + rError + rMuted +) + +type evMsg struct{ ev progress.Event } +type reporterMsg struct { + kind reporterKind + text string +} +type installDoneMsg struct{ err error } + +// chanReporter forwards installer.Reporter calls onto the wizard event channel. +type chanReporter struct{ ch chan tea.Msg } + +func (r chanReporter) Header(s string) { r.ch <- reporterMsg{kind: rHeader, text: s} } +func (r chanReporter) Info(s string) { r.ch <- reporterMsg{kind: rInfo, text: s} } +func (r chanReporter) Success(s string) { r.ch <- reporterMsg{kind: rSuccess, text: s} } +func (r chanReporter) Warn(s string) { r.ch <- reporterMsg{kind: rWarn, text: s} } +func (r chanReporter) Error(s string) { r.ch <- reporterMsg{kind: rError, text: s} } +func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text: s} } + +// ── starting the install ── + +func (m Model) startInstall() (tea.Model, tea.Cmd) { + plan := installer.PlanFromSelection(m.opts, m.selected) + // Force non-interactive Apply: guarantees no huh prompt (git/npm-retry/ + // screen-recording reminder) fires mid-alt-screen. All decisions are + // already resolved in the plan. + plan.Silent = true + + m.plan = plan + m.phases = buildPhases(plan) + m.logs = nil + m.screen = scrInstall + m.installing = true + m.done = false + m.confirmed = true + m.installTick = m.ticks + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + return m, tea.Batch(m.spawnInstall(ctx, plan), waitForEvent(m.events)) +} + +// spawnInstall runs the install engine on a background goroutine, streaming +// progress onto the event channel. It returns immediately. +func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea.Cmd { + ch := m.events + return func() tea.Msg { + go func() { + sink := func(ev progress.Event) { ch <- evMsg{ev: ev} } + restoreBrew := brew.SetProgressSink(sink) + restoreNpm := npm.SetProgressSink(sink) + err := installer.ApplyContext(ctx, plan, chanReporter{ch: ch}) + restoreBrew() + restoreNpm() + ch <- installDoneMsg{err: err} + }() + return nil + } +} + +func buildPhases(plan installer.InstallPlan) []phaseState { + var ps []phaseState + add := func(name string, total int, pkg, present bool) { + if present { + ps = append(ps, phaseState{name: name, total: total, pkg: pkg}) + } + } + add("Git identity", 1, false, !plan.PackagesOnly && !plan.SkipGit) + add(progress.PhaseHomebrew, len(plan.Formulae), true, len(plan.Formulae) > 0) + add(progress.PhaseApplications, len(plan.Casks), true, len(plan.Casks) > 0) + add(progress.PhaseNpm, len(plan.Npm), true, len(plan.Npm) > 0) + add("Shell", 1, false, !plan.PackagesOnly && plan.InstallOhMyZsh) + add("Dotfiles", 1, false, !plan.PackagesOnly && plan.DotfilesURL != "") + add("macOS prefs", 1, false, !plan.PackagesOnly && len(plan.MacOSPrefs) > 0) + return ps +} + +// ── streaming event handling ── + +func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { + switch t := msg.(type) { + case evMsg: + m.applyProgressEvent(t.ev) + return m, waitForEvent(m.events) + + case reporterMsg: + if ph := headerPhase(t.text); ph != "" { + m.activatePhase(ph) + } + if line, ok := reporterLogLine(t); ok { + m.appendLog(line) + } + return m, waitForEvent(m.events) + + case installDoneMsg: + m.installing = false + m.done = true + m.installErr = t.err + for i := range m.phases { + m.phases[i].active = false + m.phases[i].finished = true + } + if m.cancel != nil { + m.cancel() + } + return m, nil + } + return m, nil +} + +func (m *Model) applyProgressEvent(ev progress.Event) { + m.activatePhase(ev.Phase) + switch ev.Status { + case progress.StepStart: + if ev.Command != "" { + m.appendLog(logLine{mark: "$", markColor: cDim4, text: ev.Command, color: cDim}) + } + if ev.Name != "" { + m.curStep = ev.Name + } + case progress.StepOK: + m.incPhase(ev.Phase) + text := ev.Name + if ev.Detail != "" { + text += " — " + ev.Detail + } + m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}) + case progress.StepFail: + m.incPhase(ev.Phase) + m.appendLog(logLine{mark: "✗", markColor: cDanger, text: ev.Name + " (" + ev.Detail + ")", color: cDanger}) + } +} + +// activatePhase marks phase name active and everything before it finished. +func (m *Model) activatePhase(name string) { + if name == "" { + return + } + idx := -1 + for i := range m.phases { + if m.phases[i].name == name { + idx = i + break + } + } + if idx < 0 { + return + } + for i := 0; i < idx; i++ { + if !m.phases[i].finished { + m.phases[i].active = false + m.phases[i].finished = true + } + } + if !m.phases[idx].finished { + m.phases[idx].active = true + } +} + +func (m *Model) incPhase(name string) { + for i := range m.phases { + if m.phases[i].name == name { + m.phases[i].done++ + if m.phases[i].pkg && m.phases[i].done >= m.phases[i].total { + m.phases[i].active = false + m.phases[i].finished = true + } + return + } + } +} + +func (m *Model) appendLog(l logLine) { + m.logs = append(m.logs, l) + if len(m.logs) > 500 { + m.logs = m.logs[len(m.logs)-500:] + } +} + +// headerPhase maps an installer Header string onto a pipeline phase name. +func headerPhase(text string) string { + switch { + case strings.Contains(text, "Git Config"): + return "Git identity" + case strings.Contains(text, "Shell Config"): + return "Shell" + case strings.Contains(text, "Dotfiles"): + return "Dotfiles" + case strings.Contains(text, "macOS Prefer"): + return "macOS prefs" + case strings.Contains(text, "Installation"): + return progress.PhaseHomebrew + case strings.Contains(text, "NPM"): + return progress.PhaseNpm + } + return "" +} + +// reporterLogLine turns a meaningful reporter call (outcomes only) into a log +// line. Headers/info/muted are dropped to keep the log focused, matching the +// design's $cmd / ✓result cadence. +func reporterLogLine(t reporterMsg) (logLine, bool) { + text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t.text), "✓")) + switch t.kind { + case rSuccess: + return logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}, true + case rWarn: + return logLine{mark: "!", markColor: cWarn, text: text, color: cWarn}, true + case rError: + return logLine{mark: "✗", markColor: cDanger, text: text, color: cDanger}, true + } + return logLine{}, false +} + +// ── counters used by the status bar ── + +func (m Model) totalSteps() int { + n := 0 + for _, p := range m.phases { + n += p.total + } + return n +} + +func (m Model) completedSteps() int { + n := 0 + for _, p := range m.phases { + if p.pkg { + n += min(p.done, p.total) + } else if p.finished { + n += p.total + } + } + return n +} + +func (m Model) elapsed() int { + return (m.ticks - m.installTick) * int(tickInterval.Milliseconds()) / 1000 +} + +// ── key handling ── + +func (m Model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.done { + switch msg.String() { + case "r": + return m.replay() + case "q", "enter", "esc": + return m, tea.Quit + } + } + return m, nil +} + +func (m Model) replay() (tea.Model, tea.Cmd) { + nm := New(m.version, m.opts) + nm.width, nm.height = m.width, m.height + nm.ticks = m.ticks + return nm, nm.runProbe(0) +} + +// ── rendering ── + +const pipelineW = 22 + +func (m Model) installBody(w, h int) string { + // Reserve the bottom rows for the progress bar / completion summary. + footer := m.installFooter(w) + footerH := len(footer) + paneH := h - footerH - 1 + if paneH < 1 { + paneH = 1 + } + + sidebar := m.pipelineSidebar(paneH) + logPane := m.logView(w-pipelineW-1, paneH) + + var lines []string + for i := 0; i < paneH; i++ { + l, r := "", "" + if i < len(sidebar) { + l = sidebar[i] + } + if i < len(logPane) { + r = logPane[i] + } + lines = append(lines, padTo(l, pipelineW)+fg(cBorder).Render("│")+r) + } + lines = append(lines, fg(cBorder).Render(strings.Repeat("─", w))) + lines = append(lines, footer...) + return strings.Join(lines, "\n") +} + +func (m Model) pipelineSidebar(h int) []string { + rows := make([]string, 0, h) + rows = append(rows, "") + rows = append(rows, " "+fg(cDim4).Render("PIPELINE")) + rows = append(rows, "") + for _, p := range m.phases { + icon := fg(cFaint).Render("○") + nameStyle := fg(cDim3) + switch { + case p.finished: + icon = fg(cAccent).Render("✓") + nameStyle = fg(cMuted2) + case p.active: + icon = fg(cTextHi).Render(m.spinner()) + nameStyle = fg(cTextHi) + } + meta := fg(cDim4).Render(fmt.Sprintf("%d/%d", min(p.done, p.total), p.total)) + if !p.pkg { + done := 0 + if p.finished { + done = 1 + } + meta = fg(cDim4).Render(fmt.Sprintf("%d/%d", done, p.total)) + } + left := " " + icon + " " + nameStyle.Render(p.name) + rows = append(rows, bar(left, meta+" ", pipelineW)) + } + for len(rows) < h { + rows = append(rows, "") + } + return rows +} + +// logView renders the tail of the log, bottom-aligned within h rows. +func (m Model) logView(w, h int) []string { + rows := make([]string, 0, h) + start := 0 + if len(m.logs) > h { + start = len(m.logs) - h + } + // top padding so the log sits at the bottom of the pane + for i := 0; i < h-(len(m.logs)-start); i++ { + rows = append(rows, "") + } + for _, l := range m.logs[start:] { + line := " " + fg(l.markColor).Render(l.mark) + " " + fg(l.color).Render(l.text) + rows = append(rows, truncCell(line, w)) + } + return rows +} + +func (m Model) installFooter(w int) []string { + if m.done { + pkgN := len(m.plan.Formulae) + len(m.plan.Casks) + len(m.plan.Npm) + head := fg(cAccent).Render("✓") + " " + fg(cTextHi).Bold(true).Render("This Mac is dev-ready.") + + " " + fg(cDim3).Render(fmt.Sprintf("%d packages · %ds", pkgN, m.elapsed())) + if m.installErr != nil { + head = fg(cWarn).Render("!") + " " + fg(cTextHi).Bold(true).Render("Finished with some errors.") + + " " + fg(cDim3).Render("see log above") + } + next := fg(cDim2).Render("next → ") + fg(cAccentHi).Render("openboot snapshot publish") + + fg(cDim3).Render(" keeps this setup synced to your account") + return []string{truncCell(head, w), truncCell(next, w)} + } + + // Active install bar: spinner, current step, progress bar, percent. + total := m.totalSteps() + completed := m.completedSteps() + pct := 0 + if total > 0 { + pct = completed * 100 / total + } + cells := 24 + fill := 0 + if total > 0 { + fill = completed * cells / total + } + barStr := fg(cAccent).Render(strings.Repeat("▰", fill)) + fg(cFaint).Render(strings.Repeat("▰", cells-fill)) + step := m.curStep + if step == "" { + step = "starting…" + } + left := fg(cAccent).Render(m.spinner()) + " " + fg(cMuted).Bold(true).Render(padTo(truncCell(step, 18), 18)) + " " + barStr + right := fg(cMuted3).Render(fmt.Sprintf("%d%%", pct)) + return []string{bar(left, right, w)} +} diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go new file mode 100644 index 0000000..aae6fec --- /dev/null +++ b/internal/ui/tui/wizard/select.go @@ -0,0 +1,354 @@ +package wizard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/openbootdotdev/openboot/internal/config" +) + +// ── selection helpers ── + +func (m Model) isInstalled(name string) bool { return m.installed[name] } + +func (m *Model) toggle(name string) { m.selected[name] = !m.selected[name] } + +// selCount is the number of packages the user has selected. +func (m Model) selCount() int { + n := 0 + for _, v := range m.selected { + if v { + n++ + } + } + return n +} + +// toInstallCount is the number of selected packages not already installed. +func (m Model) toInstallCount() int { + n := 0 + for name, v := range m.selected { + if v && !m.installed[name] { + n++ + } + } + return n +} + +// skippedCount is the number of selected packages already present. +func (m Model) skippedCount() int { return m.selCount() - m.toInstallCount() } + +func (m Model) estMin() int { return estMinutes(m.toInstallCount()) } + +// pool returns the packages shown in the right pane: the active category, or — +// when a query is present — a substring match across the whole catalog. +func (m Model) pool() []config.Package { + q := strings.TrimSpace(strings.ToLower(m.query)) + if q != "" { + var out []config.Package + for _, c := range m.cats { + for _, p := range c.Packages { + if strings.Contains(strings.ToLower(p.Name+" "+p.Description), q) { + out = append(out, p) + } + } + } + return out + } + if m.catCur >= 0 && m.catCur < len(m.cats) { + return m.cats[m.catCur].Packages + } + return nil +} + +// selVisible is the number of package rows the list area can show. +func (m Model) selVisible() int { + v := m.height - 6 // chrome (2) + search row + blank + slack + if v < 3 { + v = 3 + } + return v +} + +func (m Model) clampSelScroll() Model { + visible := m.selVisible() + if m.rowCur < m.scroll { + m.scroll = m.rowCur + } + if m.rowCur >= m.scroll+visible { + m.scroll = m.rowCur - visible + 1 + } + if m.scroll < 0 { + m.scroll = 0 + } + return m +} + +// ── key handling ── + +func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocyclo // a keyboard dispatch table; splitting obscures the flow + pool := m.pool() + last := len(pool) - 1 + s := msg.String() + + if m.typing { + switch s { + case "esc": + m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + case "enter": + if strings.TrimSpace(m.query) != "" && len(pool) > 0 { + p := pool[clamp(m.rowCur, 0, last)] + if !m.isInstalled(p.Name) { + m.toggle(p.Name) + } + m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + } else { + return m.tryInstall() + } + case "backspace": + if len(m.query) > 0 { + m.query = m.query[:len(m.query)-1] + m.rowCur, m.scroll = 0, 0 + } + case "up": + if m.rowCur > 0 { + m.rowCur-- + } + case "down": + if m.rowCur < last { + m.rowCur++ + } + default: + if len(s) == 1 && s >= " " { + m.query += s + m.rowCur, m.scroll = 0, 0 + } + } + return m.clampSelScroll(), nil + } + + switch s { + case "q": + m.quit = true + return m, tea.Quit + case "/": + m.typing, m.query, m.rowCur, m.scroll = true, "", 0, 0 + case "esc": + m.query, m.rowCur, m.scroll = "", 0, 0 + case "up", "k": + if m.rowCur > 0 { + m.rowCur-- + } + case "down", "j": + if m.rowCur < last { + m.rowCur++ + } + case "tab", "right": + if m.query == "" && len(m.cats) > 0 { + m.catCur = (m.catCur + 1) % len(m.cats) + m.rowCur, m.scroll = 0, 0 + } + case "shift+tab", "left": + if m.query == "" && len(m.cats) > 0 { + m.catCur = (m.catCur - 1 + len(m.cats)) % len(m.cats) + m.rowCur, m.scroll = 0, 0 + } + case " ": + if len(pool) > 0 { + p := pool[clamp(m.rowCur, 0, last)] + if !m.isInstalled(p.Name) { + m.toggle(p.Name) + } + } + case "a": + for _, p := range pool { + if !m.isInstalled(p.Name) { + m.selected[p.Name] = true + } + } + case "x": + m.selected = map[string]bool{} + case "enter": + return m.tryInstall() + } + return m.clampSelScroll(), nil +} + +func (m Model) tryInstall() (tea.Model, tea.Cmd) { + if m.toInstallCount() == 0 { + return m, nil // nothing to install — stay put + } + return m.startInstall() +} + +// ── rendering ── + +const sidebarW = 22 + +func (m Model) selectBody(w, h int) string { + left := m.selectSidebar(h) + right := m.selectList(w-sidebarW-1, h) + + var lines []string + for i := 0; i < h; i++ { + l, r := "", "" + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + lines = append(lines, padTo(truncCell(l, sidebarW), sidebarW)+fg(cBorder).Render("│")+r) + } + return strings.Join(lines, "\n") +} + +func (m Model) selectSidebar(h int) []string { + rows := make([]string, 0, h) + rows = append(rows, "") + rows = append(rows, " "+fg(cDim4).Render("CATALOG")) + rows = append(rows, "") + + q := strings.TrimSpace(m.query) + for i, c := range m.cats { + selN, total := 0, len(c.Packages) + for _, p := range c.Packages { + if m.selected[p.Name] && !m.installed[p.Name] { + selN++ + } + } + active := i == m.catCur && q == "" + edge := " " + nameStyle := fg(cDim) + countStyle := fg(cDim4) + if active { + edge = fg(cAccent).Render("▎") + nameStyle = fg(cTextHi) + } + if selN > 0 { + countStyle = fg(cAccentHi) + } + count := fmt.Sprintf("%d", total) + if selN > 0 { + count = fmt.Sprintf("%d/%d", selN, total) + } + name := nameStyle.Render(strings.ToLower(c.Name)) + rows = append(rows, bar(edge+" "+name, countStyle.Render(count)+" ", sidebarW)) + } + + // Footer pinned to the bottom of the sidebar. + footer := []string{ + truncCell(fg(cAccent).Bold(true).Render(fmt.Sprintf("%d", m.selCount()))+ + fg(cDim).Render(fmt.Sprintf(" selected · ~%d min", m.estMin())), sidebarW), + } + if sk := m.skippedCount(); sk > 0 { + footer = append(footer, truncCell(fg(cDim4).Render(fmt.Sprintf("%d already installed", sk)), sidebarW)) + } + for len(rows) < h-len(footer)-1 { + rows = append(rows, "") + } + rows = append(rows, "") + rows = append(rows, footer...) + return rows +} + +func (m Model) selectList(w, h int) []string { + rows := make([]string, 0, h) + + // Search / filter row. + icon := fg(cDim3).Render("/") + if strings.TrimSpace(m.query) != "" { + icon = fg(cWarn).Bold(true).Render("/") + } + pool := m.pool() + queryText := m.query + if m.typing { + queryText += "▌" + } + if queryText == "" { + queryText = fg(cDim4).Render("type to filter — enter toggles the top hit") + } else { + queryText = fg(cTextHi).Render(queryText) + } + match := "" + if strings.TrimSpace(m.query) != "" { + match = fg(cDim3).Render(fmt.Sprintf("%d hits", len(pool))) + } + rows = append(rows, bar(" "+icon+" "+queryText, match+" ", w)) + rows = append(rows, "") + + // Package rows. + visible := h - len(rows) + m2 := m.clampSelScroll() + start := m2.scroll + end := start + visible + if end > len(pool) { + end = len(pool) + } + for i := start; i < end; i++ { + rows = append(rows, m.renderRow(pool[i], i == m2.rowCur, w)) + } + for len(rows) < h { + rows = append(rows, "") + } + return rows +} + +func (m Model) renderRow(p config.Package, cursor bool, w int) string { + installed := m.installed[p.Name] + on := m.selected[p.Name] + + box := fg(cDim3).Render("◯") + switch { + case installed: + box = fg(cInstalled).Render("✓") + case on: + box = fg(cAccent).Render("◉") + } + + nameStyle := fg(cMuted) + switch { + case installed: + nameStyle = fg(cDim2) + case cursor: + nameStyle = fg(cWhite).Bold(true) + case on: + nameStyle = fg(cText) + } + + tail := fg(cDim3).Render(pkgType(p)) + if installed { + tail = fg(cInstalled).Render("installed") + } + + name := padTo(nameStyle.Render(p.Name), 20) + rowPrefix := " " + if cursor { + rowPrefix = fg(cAccent).Render("› ") + } + left := rowPrefix + box + " " + name + " " + fg(cDim).Render(p.Description) + return bar(truncCell(left, w-12), tail+" ", w) +} + +func pkgType(p config.Package) string { + switch { + case p.IsNpm: + return "npm" + case p.IsCask: + return "cask" + default: + return "brew" + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go new file mode 100644 index 0000000..5ce3369 --- /dev/null +++ b/internal/ui/tui/wizard/status.go @@ -0,0 +1,32 @@ +package wizard + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// statusContent returns the bottom status-bar pieces for the current screen: +// the mode badge label + color, the keybinding hint, and the right-aligned info. +func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right string) { + switch m.screen { + case scrBoot: + if m.probeIdx < len(m.probes) { + return "BOOT", cInfo, "probing this Mac…", "~ % openboot install" + } + return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", "~ % openboot install" + + case scrSelect: + return "SELECT", cAccent, + "↑↓/jk move · space toggle · ⇥ category · / filter · a all · x clear · ↵ install", + fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) + + default: // scrInstall + if m.done { + return "DONE", cAccent, "r replay from boot · q quit", + fmt.Sprintf("%d steps · %ds", m.totalSteps(), m.elapsed()) + } + return "INSTALL", cWarn, "installing — everything is logged to ~/.openboot/logs", + fmt.Sprintf("%d/%d · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) + } +} diff --git a/internal/ui/tui/wizard/styles.go b/internal/ui/tui/wizard/styles.go new file mode 100644 index 0000000..d552770 --- /dev/null +++ b/internal/ui/tui/wizard/styles.go @@ -0,0 +1,39 @@ +package wizard + +import "github.com/charmbracelet/lipgloss" + +// Palette — mirrors the OpenBoot TUI Redesign v5 design tokens. The two anchor +// colors (accent green #22c55e, info cyan #06b6d4) already match the existing +// internal/ui palette; the rest are the grey ramp and status hues the design +// leans on for depth. +var ( + cAccent = lipgloss.Color("#22c55e") // primary green + cAccentHi = lipgloss.Color("#4ade80") // bright green — times, links + cInfo = lipgloss.Color("#06b6d4") // cyan — probing / active spinner + cWarn = lipgloss.Color("#f59e0b") // amber — installing / active search + cDanger = lipgloss.Color("#ef4444") // red — failures + + cWhite = lipgloss.Color("#ffffff") // cursor row name + cTextHi = lipgloss.Color("#e4e4e7") // emphasized body text + cText = lipgloss.Color("#d4d4d8") // body text + cMuted = lipgloss.Color("#a1a1aa") // secondary text + cMuted2 = lipgloss.Color("#8a8a92") + cMuted3 = lipgloss.Color("#71717a") + cDim = lipgloss.Color("#63636c") + cDim2 = lipgloss.Color("#52525b") + cDim3 = lipgloss.Color("#3f3f46") + cDim4 = lipgloss.Color("#3a3a41") + cFaint = lipgloss.Color("#2e2e34") + + cInstalled = lipgloss.Color("#3f6b4a") // dim green — already-installed rows + cBorder = lipgloss.Color("#2b2b33") // panel dividers (brighter than the + // design's #1c1c22 so it reads in a terminal) +) + +// fg returns a foreground-only style for c. +func fg(c lipgloss.Color) lipgloss.Style { + return lipgloss.NewStyle().Foreground(c) +} + +// spinnerFrames matches the braille spinner used across the codebase and the design. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"} diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go new file mode 100644 index 0000000..1254599 --- /dev/null +++ b/internal/ui/tui/wizard/wizard.go @@ -0,0 +1,286 @@ +// Package wizard implements the OpenBoot install TUI (Redesign v5): a single +// full-screen bubbletea program that flows boot-probe → two-pane select → +// live pipeline install, under a persistent title bar and status bar. +// +// It replaces the previous interactive planning prompts (preset select, package +// selector, per-step confirms) and the linear Apply output. Non-interactive +// paths (--silent, --dry-run, presets, --from, -u, sync) never reach here. +package wizard + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/installer" +) + +type screen int + +const ( + scrBoot screen = iota + scrSelect + scrInstall +) + +// tickInterval drives the spinner and the derived elapsed clock. +const tickInterval = 120 * time.Millisecond + +type tickMsg struct{} + +// Model is the unified install-wizard state. +type Model struct { + version string + opts *config.InstallOptions + + width, height int + screen screen + ticks int // monotonic, drives spinner + elapsed + quit bool + confirmed bool // true once install has been kicked off + + // ── boot ── + probes []probeRow + probeIdx int // index of the running probe; == len(probes) when done + loadouts []loadout + loadCur int + installed map[string]bool // catalog packages already present on this Mac + + // ── select ── + cats []config.Category + catCur int + rowCur int + scroll int + query string + typing bool + selected map[string]bool + + // ── install ── + events chan tea.Msg + plan installer.InstallPlan + phases []phaseState + logs []logLine + curStep string + installing bool + done bool + installErr error + installTick int // ticks value when install started, for elapsed + cancel context.CancelFunc +} + +// New builds a wizard model for the given version and resolved install options. +func New(version string, opts *config.InstallOptions) Model { + return Model{ + version: version, + opts: opts, + screen: scrBoot, + probes: newProbes(), + loadouts: newLoadouts(), + installed: map[string]bool{}, + cats: config.GetCategories(), + selected: map[string]bool{}, + events: make(chan tea.Msg, 1024), + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(tickCmd(), m.runProbe(0)) +} + +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +// waitForEvent blocks on the install event channel and delivers the next msg. +func waitForEvent(ch chan tea.Msg) tea.Cmd { + return func() tea.Msg { return <-ch } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + m.ticks++ + // Keep ticking while anything is animating (probing or installing). + return m, tickCmd() + + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + if m.cancel != nil { + m.cancel() + } + m.quit = true + return m, tea.Quit + } + switch m.screen { + case scrBoot: + return m.updateBoot(msg) + case scrSelect: + return m.updateSelect(msg) + case scrInstall: + return m.updateInstall(msg) + } + + case probeDoneMsg: + return m.onProbeDone(msg) + + case evMsg, reporterMsg, installDoneMsg: + return m.onInstallEvent(msg) + } + return m, nil +} + +func (m Model) spinner() string { + return spinnerFrames[m.ticks%len(spinnerFrames)] +} + +// ── View / chrome ────────────────────────────────────────────────────────── + +func (m Model) View() string { + if m.width == 0 || m.height == 0 { + return "" + } + bodyH := m.height - 2 + if bodyH < 1 { + bodyH = 1 + } + + var body string + switch m.screen { + case scrBoot: + body = m.bootBody(m.width, bodyH) + case scrSelect: + body = m.selectBody(m.width, bodyH) + case scrInstall: + body = m.installBody(m.width, bodyH) + } + + return m.titleBar() + "\n" + fitBlock(body, m.width, bodyH) + "\n" + m.statusBar() +} + +func (m Model) crumb() string { + switch m.screen { + case scrBoot: + return "setup" + case scrSelect: + return "select packages" + default: + if m.done { + return "done" + } + return "installing" + } +} + +func (m Model) titleBar() string { + left := fg(cAccent).Render("▲") + " " + + fg(cMuted).Render("openboot") + " " + + fg(cDim3).Render("v"+m.version) + right := fg(cDim3).Render(m.crumb()) + return bar(left, right, m.width) +} + +func (m Model) statusBar() string { + mode, modeColor, keys, right := m.statusContent() + badge := lipgloss.NewStyle(). + Background(modeColor). + Foreground(lipgloss.Color("#08080a")). + Bold(true). + Padding(0, 1). + Render(mode) + left := badge + " " + fg(cDim2).Render(keys) + return bar(left, fg(cMuted3).Render(right), m.width) +} + +// bar lays out left- and right-aligned segments across width w, truncating the +// left segment (by visual width) if the two would collide. +func bar(left, right string, w int) string { + lw, rw := lipgloss.Width(left), lipgloss.Width(right) + if lw+rw+1 > w { + // Truncate the left content to make room for the right. + maxLeft := w - rw - 1 + if maxLeft < 0 { + maxLeft = 0 + } + left = lipgloss.NewStyle().MaxWidth(maxLeft).Render(left) + lw = lipgloss.Width(left) + } + gap := w - lw - rw + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + +// fitBlock forces s to exactly h lines of width w: pads short lines with +// spaces, truncates long ones, and pads/truncates the line count. +func fitBlock(s string, w, h int) string { + lines := strings.Split(s, "\n") + out := make([]string, 0, h) + for i := 0; i < h; i++ { + var line string + if i < len(lines) { + line = lines[i] + } + if lipgloss.Width(line) > w { + line = lipgloss.NewStyle().MaxWidth(w).Render(line) + } + if pad := w - lipgloss.Width(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +// padTo right-pads a rendered cell to visual width w (no truncation). +func padTo(s string, w int) string { + if pad := w - lipgloss.Width(s); pad > 0 { + return s + strings.Repeat(" ", pad) + } + return s +} + +// truncCell truncates a rendered string to visual width w. +func truncCell(s string, w int) string { + if w < 0 { + w = 0 + } + if lipgloss.Width(s) <= w { + return s + } + return lipgloss.NewStyle().MaxWidth(w).Render(s) +} + +// Run launches the wizard. It returns whether an install was started (confirmed) +// and any error from the install run. Stray stdout from the install engine is +// redirected away from the alt-screen for the program's lifetime. +func Run(version string, opts *config.InstallOptions) (confirmed bool, err error) { + m := New(version, opts) + + realOut := os.Stdout + if devnull, derr := os.OpenFile(os.DevNull, os.O_WRONLY, 0); derr == nil { + os.Stdout = devnull + defer func() { + os.Stdout = realOut + _ = devnull.Close() + }() + } + + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(realOut), tea.WithInput(os.Stdin)) + final, runErr := p.Run() + if runErr != nil { + return false, fmt.Errorf("run wizard: %w", runErr) + } + fm := final.(Model) + return fm.confirmed, fm.installErr +} diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go new file mode 100644 index 0000000..2b2a2c9 --- /dev/null +++ b/internal/ui/tui/wizard/wizard_test.go @@ -0,0 +1,229 @@ +package wizard + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/installer" + "github.com/openbootdotdev/openboot/internal/macos" + "github.com/openbootdotdev/openboot/internal/progress" +) + +func sized(w, h int) Model { + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0"}) + return send(m, tea.WindowSizeMsg{Width: w, Height: h}) +} + +func TestBootProbesGateInputThenPickLoadout(t *testing.T) { + m := sized(96, 30) + + // While probing, loadout keys are ignored. + m = send(m, key("2")) + require.Equal(t, scrBoot, m.screen) + + m = finishProbes(m) + require.Equal(t, len(m.probes), m.probeIdx, "all probes done") + assert.True(t, m.installed["git"], "scan populated the installed set") + + m = send(m, key("2")) + assert.Equal(t, scrSelect, m.screen) + assert.Equal(t, config.GetPackagesForPreset("developer"), m.selected) +} + +func TestBootHandPickStartsEmpty(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + require.Equal(t, scrSelect, m.screen) + assert.Zero(t, m.selCount()) +} + +func TestSelectToggleSelectAllClear(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) // hand-pick, empty selection + m.installed = map[string]bool{} + + require.Zero(t, m.selCount()) + m = send(m, key("space")) + assert.Equal(t, 1, m.selCount(), "space toggles the cursor row") + + m = send(m, key("a")) + assert.Equal(t, len(m.pool()), m.selCount(), "a selects all in the category") + + m = send(m, key("x")) + assert.Zero(t, m.selCount(), "x clears selection") +} + +func TestSelectInstalledRowNotToggleable(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + // Force the cursor's package to be "installed". + pool := m.pool() + require.NotEmpty(t, pool) + m.installed = map[string]bool{pool[0].Name: true} + m = send(m, key("space")) + assert.Zero(t, m.selCount(), "installed rows can't be selected") +} + +func TestSelectFilter(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) // developer + m = send(m, key("/")) + require.True(t, m.typing) + for _, r := range "docker" { + m = send(m, key(string(r))) + } + pool := m.pool() + require.NotEmpty(t, pool) + for _, p := range pool { + assert.Contains(t, strings.ToLower(p.Name+" "+p.Description), "docker") + } + // Esc exits filter. + m = send(m, key("esc")) + assert.False(t, m.typing) + assert.Empty(t, m.query) +} + +func TestSelectCategoryCycle(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + start := m.catCur + m = send(m, key("tab")) + assert.Equal(t, (start+1)%len(m.cats), m.catCur) +} + +func TestTryInstallNoopWhenNothingToInstall(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) // empty selection + m = send(m, key("enter")) + assert.Equal(t, scrSelect, m.screen, "enter with nothing to install stays on select") +} + +func TestBuildPhases(t *testing.T) { + plan := installer.InstallPlan{ + Formulae: []string{"a", "b"}, + Casks: []string{"c"}, + Npm: []string{"d"}, + InstallOhMyZsh: true, + DotfilesURL: "x", + MacOSPrefs: make([]macos.Preference, 1), + } + phases := buildPhases(plan) + var names []string + for _, p := range phases { + names = append(names, p.name) + } + assert.Equal(t, []string{ + "Git identity", progress.PhaseHomebrew, progress.PhaseApplications, + progress.PhaseNpm, "Shell", "Dotfiles", "macOS prefs", + }, names) + + // PackagesOnly drops every config phase. + po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}) + require.Len(t, po, 1) + assert.Equal(t, progress.PhaseHomebrew, po[0].name) +} + +func TestProgressEventsDrivePhasesAndLog(t *testing.T) { + // SkipGit drops the "Git identity" phase so the package phases lead. + plan := installer.InstallPlan{Formulae: []string{"a", "b"}, Casks: []string{"c"}, SkipGit: true} + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.phases = buildPhases(plan) + + feed := func(ev progress.Event) { + next, _ := m.Update(evMsg{ev: ev}) + m = next.(Model) + } + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepStart, Command: "brew install a"}) + assert.True(t, phaseByName(m, progress.PhaseHomebrew).active, "homebrew active") + assert.Equal(t, "a", m.curStep) + + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: "1.0s"}) + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "b", Status: progress.StepOK, Detail: "2.0s"}) + assert.True(t, phaseByName(m, progress.PhaseHomebrew).finished, "homebrew finished at 2/2") + assert.Equal(t, 2, m.completedSteps()) + + feed(progress.Event{Phase: progress.PhaseApplications, Name: "c", Status: progress.StepStart, Command: "brew install --cask c"}) + assert.True(t, phaseByName(m, progress.PhaseApplications).active) + + // Log carries $cmd and ✓result lines. + joined := strings.Join(logTexts(m.logs), "\n") + assert.Contains(t, joined, "brew install a") + assert.Contains(t, joined, "a — 1.0s") +} + +func phaseByName(m Model, name string) phaseState { + for _, p := range m.phases { + if p.name == name { + return p + } + } + return phaseState{} +} + +func TestReporterHeaderActivatesConfigPhase(t *testing.T) { + plan := installer.InstallPlan{InstallOhMyZsh: true, SkipGit: true} + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.phases = buildPhases(plan) + require.Len(t, m.phases, 1) + + next, _ := m.Update(reporterMsg{kind: rHeader, text: "Shell Configuration"}) + m = next.(Model) + assert.True(t, m.phases[0].active, "shell header activates the Shell phase") +} + +func TestInstallDoneMarksAllFinished(t *testing.T) { + plan := installer.InstallPlan{Formulae: []string{"a"}, InstallOhMyZsh: true} + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.installing = true + m.phases = buildPhases(plan) + + next, _ := m.Update(installDoneMsg{}) + m = next.(Model) + assert.False(t, m.installing) + assert.True(t, m.done) + for _, p := range m.phases { + assert.True(t, p.finished) + } +} + +// TestViewDimensions asserts every screen fills exactly the terminal box. +func TestViewDimensions(t *testing.T) { + const W, H = 90, 28 + cases := map[string]Model{} + cases["boot-probing"] = sized(W, H) + cases["boot-loadouts"] = finishProbes(sized(W, H)) + cases["select"] = send(finishProbes(sized(W, H)), key("2")) + cases["install"] = installFrame(finishProbes(sized(W, H)), W, H) + + for name, m := range cases { + t.Run(name, func(t *testing.T) { + lines := strings.Split(m.View(), "\n") + assert.Len(t, lines, H, "line count == height") + for i, ln := range lines { + assert.LessOrEqualf(t, lipgloss.Width(ln), W, "line %d within width", i) + } + }) + } +} + +func TestViewEmptyBeforeSize(t *testing.T) { + m := New("1.4.0", &config.InstallOptions{}) + assert.Empty(t, m.View(), "renders nothing until sized") +} + +func logTexts(ls []logLine) []string { + out := make([]string, len(ls)) + for i, l := range ls { + out[i] = l.text + } + return out +} From bfd6979bb17383750b9d925c30f5971d237ffb08 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:18:25 +0800 Subject: [PATCH 04/12] test(tui): exercise install wiring end-to-end against a dry-run plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a test that drives the real spawnInstall goroutine → brew/npm sinks → installer.ApplyContext → channel Reporter → Update loop with a dry-run plan, asserting the model reaches done with every phase finished. This covers the integration seam the other model tests stub out with synthetic events. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/ui/tui/wizard/wizard_test.go | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 2b2a2c9..0c6306e 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -1,8 +1,10 @@ package wizard import ( + "context" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -220,6 +222,45 @@ func TestViewEmptyBeforeSize(t *testing.T) { assert.Empty(t, m.View(), "renders nothing until sized") } +// TestInstallGoroutineStreamsToDone exercises the real wiring end-to-end +// against a dry-run plan: spawnInstall's goroutine sets the brew/npm sinks, +// runs installer.ApplyContext with the channel Reporter, and the model drains +// the channel through Update until installDoneMsg. Dry-run means nothing is +// actually installed. +func TestInstallGoroutineStreamsToDone(t *testing.T) { + opts := &config.InstallOptions{Version: "1", DryRun: true} + m := New("1", opts) + m.screen = scrInstall + + plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan.Silent = true + m.plan = plan + m.phases = buildPhases(plan) + m.installing = true + + // Start the background install; the cmd returns nil and feeds m.events. + m.spawnInstall(context.Background(), plan)() + + // Drain the channel through Update until the install reports done. + deadline := time.After(30 * time.Second) + for !m.done { + select { + case msg := <-m.events: + next, _ := m.Update(msg) + m = next.(Model) + case <-deadline: + t.Fatal("install did not complete within 30s") + } + } + + assert.False(t, m.installing) + assert.NoError(t, m.installErr) + // Every phase ends finished once done. + for _, p := range m.phases { + assert.Truef(t, p.finished, "phase %q finished", p.name) + } +} + func logTexts(ls []logLine) []string { out := make([]string, len(ls)) for i, l := range ls { From e1763f6d26cb1c1918f9dcccd711adb3b673cd4a Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:25:53 +0800 Subject: [PATCH 05/12] fix(tui): count only not-installed packages in the install pipeline buildPhases previously took package-phase totals from the full selection, but the engine emits progress events only for packages it actually installs (already-present ones are filtered first). On a non-fresh Mac that made the sidebar counts, progress bar, and "N packages" summary overcount. buildPhases now excludes anything in the scanned installed set, and the DONE summary derives its count from the package-phase totals. Also refresh CLAUDE.md: the install flow now routes bare interactive runs through internal/ui/tui/wizard, with a Where-to-Look row for it. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- CLAUDE.md | 5 ++-- internal/ui/tui/wizard/frames_test.go | 2 +- internal/ui/tui/wizard/install.go | 38 ++++++++++++++++++++++----- internal/ui/tui/wizard/wizard_test.go | 24 ++++++++++++----- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2dc2d86..51ff8d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ OpenBoot is a **macOS-only** Go 1.25 CLI that automates dev-environment setup: Homebrew packages/casks, npm globals, Oh-My-Zsh, macOS `defaults`, and dotfiles. Built on **Cobra** (CLI) + **Charmbracelet** (bubbletea / lipgloss / huh for TUI). Entry point: `cmd/openboot/main.go` → `internal/cli.Execute()`. -Core flow: `openboot install` runs a 7-step wizard in `internal/installer/installer.go`. +Core flow: `openboot install` orchestrates plan → apply in `internal/installer/installer.go`. Bare interactive `openboot install` on a TTY runs the full-screen install TUI in `internal/ui/tui/wizard/` (boot probe → select → live install); explicit sources (`-p`, `--from`, `-u`, sync), `--silent`, and `--dry-run` use the linear flow. For full contribution guide (test layering L1–L4, Runner interface, hook setup) see @CONTRIBUTING.md. For AI agents: @AGENTS.md indexes invariants enforced by `internal/archtest`; @docs/HARNESS.md is the steering meta-doc for where to encode new rules. @@ -75,7 +75,8 @@ scripts/ | Task | Location | Notes | |------|----------|-------| | Add CLI command | `internal/cli/` | Register in `root.go init()`, follow cobra pattern | -| Change install flow | `internal/installer/installer.go` | 7-step wizard orchestrator | +| Change install flow | `internal/installer/installer.go` | plan → apply orchestrator; `PlanFromSelection` builds a plan from TUI picks | +| Change interactive install TUI | `internal/ui/tui/wizard/` | Redesign v5: boot/select/install screens; live install streams `internal/progress` events (brew/npm `SetProgressSink`) | | Change sync behavior | `internal/sync/diff.go`, `internal/sync/plan.go` | Diff → confirm → execute | | Add package category | `openboot.dev/src/lib/package-metadata.ts` | Server is source of truth; CLI fetches `/api/packages` and caches 24h in `~/.openboot/packages-cache.json`. `data/packages.yaml` is fallback only. | | Modify presets | `internal/config/data/presets.yaml` | 3 presets: minimal, developer, full | diff --git a/internal/ui/tui/wizard/frames_test.go b/internal/ui/tui/wizard/frames_test.go index 3704e5a..a07907e 100644 --- a/internal/ui/tui/wizard/frames_test.go +++ b/internal/ui/tui/wizard/frames_test.go @@ -99,7 +99,7 @@ func installFrame(m Model, _, _ int) Model { } m.plan.InstallOhMyZsh = true m.plan.DotfilesURL = "https://github.com/x/dotfiles" - m.phases = buildPhases(m.plan) + m.phases = buildPhases(m.plan, m.installed) m.installTick = m.ticks m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepStart, Command: "brew install node"}}) diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index 5f05974..edfae6f 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -71,7 +71,7 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { plan.Silent = true m.plan = plan - m.phases = buildPhases(plan) + m.phases = buildPhases(plan, m.installed) m.logs = nil m.screen = scrInstall m.installing = true @@ -103,23 +103,49 @@ func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea } } -func buildPhases(plan installer.InstallPlan) []phaseState { +// buildPhases derives the pipeline sidebar from the plan. Package-phase totals +// count only packages not already installed (matching what the engine will +// actually emit events for), so the sidebar counts and progress bar stay +// accurate on a non-fresh Mac. +func buildPhases(plan installer.InstallPlan, installed map[string]bool) []phaseState { + countNew := func(names []string) int { + n := 0 + for _, name := range names { + if !installed[name] { + n++ + } + } + return n + } var ps []phaseState add := func(name string, total int, pkg, present bool) { if present { ps = append(ps, phaseState{name: name, total: total, pkg: pkg}) } } + nFormulae, nCasks, nNpm := countNew(plan.Formulae), countNew(plan.Casks), countNew(plan.Npm) add("Git identity", 1, false, !plan.PackagesOnly && !plan.SkipGit) - add(progress.PhaseHomebrew, len(plan.Formulae), true, len(plan.Formulae) > 0) - add(progress.PhaseApplications, len(plan.Casks), true, len(plan.Casks) > 0) - add(progress.PhaseNpm, len(plan.Npm), true, len(plan.Npm) > 0) + add(progress.PhaseHomebrew, nFormulae, true, nFormulae > 0) + add(progress.PhaseApplications, nCasks, true, nCasks > 0) + add(progress.PhaseNpm, nNpm, true, nNpm > 0) add("Shell", 1, false, !plan.PackagesOnly && plan.InstallOhMyZsh) add("Dotfiles", 1, false, !plan.PackagesOnly && plan.DotfilesURL != "") add("macOS prefs", 1, false, !plan.PackagesOnly && len(plan.MacOSPrefs) > 0) return ps } +// pkgCount is the number of packages that will actually be installed, derived +// from the package-phase totals. +func (m Model) pkgCount() int { + n := 0 + for _, p := range m.phases { + if p.pkg { + n += p.total + } + } + return n +} + // ── streaming event handling ── func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -389,7 +415,7 @@ func (m Model) logView(w, h int) []string { func (m Model) installFooter(w int) []string { if m.done { - pkgN := len(m.plan.Formulae) + len(m.plan.Casks) + len(m.plan.Npm) + pkgN := m.pkgCount() head := fg(cAccent).Render("✓") + " " + fg(cTextHi).Bold(true).Render("This Mac is dev-ready.") + " " + fg(cDim3).Render(fmt.Sprintf("%d packages · %ds", pkgN, m.elapsed())) if m.installErr != nil { diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 0c6306e..e4896ef 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -115,7 +115,7 @@ func TestBuildPhases(t *testing.T) { DotfilesURL: "x", MacOSPrefs: make([]macos.Preference, 1), } - phases := buildPhases(plan) + phases := buildPhases(plan, nil) var names []string for _, p := range phases { names = append(names, p.name) @@ -126,17 +126,29 @@ func TestBuildPhases(t *testing.T) { }, names) // PackagesOnly drops every config phase. - po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}) + po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}, nil) require.Len(t, po, 1) assert.Equal(t, progress.PhaseHomebrew, po[0].name) } +func TestBuildPhasesExcludesInstalledFromCounts(t *testing.T) { + plan := installer.InstallPlan{Formulae: []string{"a", "b", "c"}, SkipGit: true} + phases := buildPhases(plan, map[string]bool{"a": true, "b": true}) + require.Len(t, phases, 1) + assert.Equal(t, progress.PhaseHomebrew, phases[0].name) + assert.Equal(t, 1, phases[0].total, "only the not-installed package counts") + + // All installed → no package phase at all. + none := buildPhases(plan, map[string]bool{"a": true, "b": true, "c": true}) + assert.Empty(t, none) +} + func TestProgressEventsDrivePhasesAndLog(t *testing.T) { // SkipGit drops the "Git identity" phase so the package phases lead. plan := installer.InstallPlan{Formulae: []string{"a", "b"}, Casks: []string{"c"}, SkipGit: true} m := New("1", &config.InstallOptions{}) m.screen = scrInstall - m.phases = buildPhases(plan) + m.phases = buildPhases(plan, nil) feed := func(ev progress.Event) { next, _ := m.Update(evMsg{ev: ev}) @@ -173,7 +185,7 @@ func TestReporterHeaderActivatesConfigPhase(t *testing.T) { plan := installer.InstallPlan{InstallOhMyZsh: true, SkipGit: true} m := New("1", &config.InstallOptions{}) m.screen = scrInstall - m.phases = buildPhases(plan) + m.phases = buildPhases(plan, nil) require.Len(t, m.phases, 1) next, _ := m.Update(reporterMsg{kind: rHeader, text: "Shell Configuration"}) @@ -186,7 +198,7 @@ func TestInstallDoneMarksAllFinished(t *testing.T) { m := New("1", &config.InstallOptions{}) m.screen = scrInstall m.installing = true - m.phases = buildPhases(plan) + m.phases = buildPhases(plan, nil) next, _ := m.Update(installDoneMsg{}) m = next.(Model) @@ -235,7 +247,7 @@ func TestInstallGoroutineStreamsToDone(t *testing.T) { plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) plan.Silent = true m.plan = plan - m.phases = buildPhases(plan) + m.phases = buildPhases(plan, nil) m.installing = true // Start the background install; the cmd returns nil and feeds m.events. From db7d419156b872b35d467adf8eee774af25b0d8f Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 7 Jul 2026 23:35:00 +0800 Subject: [PATCH 06/12] feat(tui): capture git identity when none is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh Macs — the wizard's core audience — often have no git identity, and the previous behavior silently skipped git setup. Add a minimal name/email screen shown between select and install, but only when git isn't already configured (and not with --packages-only). Existing identities skip it and are reused as before. The captured values flow into the plan (overriding SkipGit); the lookup is behind a test seam so the routing is covered deterministically. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/ui/tui/wizard/frames_test.go | 7 ++ internal/ui/tui/wizard/git.go | 103 ++++++++++++++++++++++++++ internal/ui/tui/wizard/install.go | 8 ++ internal/ui/tui/wizard/select.go | 6 ++ internal/ui/tui/wizard/status.go | 3 + internal/ui/tui/wizard/wizard.go | 12 +++ internal/ui/tui/wizard/wizard_test.go | 58 +++++++++++++++ 7 files changed, 197 insertions(+) create mode 100644 internal/ui/tui/wizard/git.go diff --git a/internal/ui/tui/wizard/frames_test.go b/internal/ui/tui/wizard/frames_test.go index a07907e..e15ec46 100644 --- a/internal/ui/tui/wizard/frames_test.go +++ b/internal/ui/tui/wizard/frames_test.go @@ -78,6 +78,13 @@ func TestDumpFrames(t *testing.T) { } t.Log("\n===== SELECT (filter 'doc') =====\n" + f.View()) + // Git identity capture. + g := send(sel, key("2")) // reuse a select model + g.screen = scrGit + g.gitName = "Jane Developer" + g.gitField = 1 + t.Log("\n===== GIT (capture) =====\n" + g.View()) + // Install: synthetic pipeline (no real Apply). inst := installFrame(m, W, H) t.Log("\n===== INSTALL (running) =====\n" + inst.View()) diff --git a/internal/ui/tui/wizard/git.go b/internal/ui/tui/wizard/git.go new file mode 100644 index 0000000..1e3ec5c --- /dev/null +++ b/internal/ui/tui/wizard/git.go @@ -0,0 +1,103 @@ +package wizard + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/openbootdotdev/openboot/internal/system" +) + +// gitConfigLookup is a seam so tests can stub the existing-identity probe. +var gitConfigLookup = system.GetExistingGitConfig + +// needsGitCapture reports whether the wizard should prompt for a git identity +// before installing: only when system config is not fully set and the run +// configures system state. It also returns any partial existing values to +// prefill. +func (m Model) needsGitCapture() (need bool, name, email string) { + if m.opts.PackagesOnly { + return false, "", "" + } + name, email = gitConfigLookup() + if name != "" && email != "" { + return false, name, email + } + return true, name, email +} + +func (m Model) updateGit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.screen = scrSelect + return m, nil + case "tab", "down", "up", "shift+tab": + m.gitField = (m.gitField + 1) % 2 + case "enter": + if m.gitField == 0 { + m.gitField = 1 + return m, nil + } + if strings.TrimSpace(m.gitName) != "" && strings.TrimSpace(m.gitEmail) != "" { + return m.startInstall() + } + // Focus whichever field is still empty. + if strings.TrimSpace(m.gitName) == "" { + m.gitField = 0 + } + return m, nil + case "backspace": + if m.gitField == 0 { + m.gitName = trimLast(m.gitName) + } else { + m.gitEmail = trimLast(m.gitEmail) + } + default: + if s := msg.String(); len(s) == 1 && s >= " " { + if m.gitField == 0 { + m.gitName += s + } else { + m.gitEmail += s + } + } + } + return m, nil +} + +func trimLast(s string) string { + if s == "" { + return s + } + return s[:len(s)-1] +} + +func (m Model) gitBody(_, _ int) string { + const pad = " " + var b []string + b = append(b, "") + b = append(b, "") + b = append(b, pad+fg(cTextHi).Bold(true).Render("Set your git identity")) + b = append(b, pad+fg(cDim3).Render("No git config found — used to author your commits on this Mac.")) + b = append(b, "") + b = append(b, pad+m.gitFieldRow(0, "Name", m.gitName, "Jane Developer")) + b = append(b, pad+m.gitFieldRow(1, "Email", m.gitEmail, "jane@example.com")) + b = append(b, "") + b = append(b, pad+fg(cDim3).Render("↑↓/tab switch field · ↵ continue · esc back")) + return strings.Join(b, "\n") +} + +func (m Model) gitFieldRow(idx int, label, value, placeholder string) string { + focused := m.gitField == idx + sep := fg(cBorder).Render("┃") + var val string + switch { + case value == "" && !focused: + val = fg(cDim4).Render(placeholder) + case focused: + sep = fg(cAccent).Render("┃") + val = fg(cTextHi).Render(value) + fg(cAccent).Render("▌") + default: + val = fg(cTextHi).Render(value) + } + return fg(cDim).Render(padTo(label, 7)) + " " + sep + " " + val +} diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index edfae6f..bb49f2c 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -65,6 +65,14 @@ func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text func (m Model) startInstall() (tea.Model, tea.Cmd) { plan := installer.PlanFromSelection(m.opts, m.selected) + // Apply a git identity captured on the git screen (fresh Mac). When git is + // already configured, these stay empty and PlanFromSelection's existing + // config is used instead. + if strings.TrimSpace(m.gitName) != "" && strings.TrimSpace(m.gitEmail) != "" { + plan.GitName = m.gitName + plan.GitEmail = m.gitEmail + plan.SkipGit = false + } // Force non-interactive Apply: guarantees no huh prompt (git/npm-retry/ // screen-recording reminder) fires mid-alt-screen. All decisions are // already resolved in the plan. diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index aae6fec..93c5722 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -180,6 +180,12 @@ func (m Model) tryInstall() (tea.Model, tea.Cmd) { if m.toInstallCount() == 0 { return m, nil // nothing to install — stay put } + // Capture a git identity first when none is configured (fresh Mac). + if need, name, email := m.needsGitCapture(); need { + m.gitName, m.gitEmail, m.gitField = name, email, 0 + m.screen = scrGit + return m, nil + } return m.startInstall() } diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 5ce3369..f49107e 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -21,6 +21,9 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s "↑↓/jk move · space toggle · ⇥ category · / filter · a all · x clear · ↵ install", fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) + case scrGit: + return "GIT", cAccent, "↑↓/tab switch field · ↵ continue · esc back", "identity for your commits" + default: // scrInstall if m.done { return "DONE", cAccent, "r replay from boot · q quit", diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 1254599..4489c1b 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -26,6 +26,7 @@ type screen int const ( scrBoot screen = iota scrSelect + scrGit scrInstall ) @@ -61,6 +62,11 @@ type Model struct { typing bool selected map[string]bool + // ── git identity (captured only when none is configured) ── + gitName string + gitEmail string + gitField int // 0 = name, 1 = email + // ── install ── events chan tea.Msg plan installer.InstallPlan @@ -126,6 +132,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateBoot(msg) case scrSelect: return m.updateSelect(msg) + case scrGit: + return m.updateGit(msg) case scrInstall: return m.updateInstall(msg) } @@ -160,6 +168,8 @@ func (m Model) View() string { body = m.bootBody(m.width, bodyH) case scrSelect: body = m.selectBody(m.width, bodyH) + case scrGit: + body = m.gitBody(m.width, bodyH) case scrInstall: body = m.installBody(m.width, bodyH) } @@ -173,6 +183,8 @@ func (m Model) crumb() string { return "setup" case scrSelect: return "select packages" + case scrGit: + return "git identity" default: if m.done { return "done" diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index e4896ef..801e4d4 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -106,6 +106,61 @@ func TestTryInstallNoopWhenNothingToInstall(t *testing.T) { assert.Equal(t, scrSelect, m.screen, "enter with nothing to install stays on select") } +func TestGitCaptureWhenUnconfigured(t *testing.T) { + defer stubGitConfig("", "")() + + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) // developer — has packages to install + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrGit, m.screen, "no git config routes to the capture screen") + + for _, r := range "Jane Dev" { + m = send(m, key(string(r))) + } + m = send(m, key("tab")) + for _, r := range "jane@ex.io" { + m = send(m, key(string(r))) + } + // Enter on the email field with both filled proceeds to install. + next, _ := m.Update(key("enter")) + m = next.(Model) + + require.Equal(t, scrInstall, m.screen) + assert.Equal(t, "Jane Dev", m.plan.GitName) + assert.Equal(t, "jane@ex.io", m.plan.GitEmail) + assert.False(t, m.plan.SkipGit) +} + +func TestGitCaptureSkippedWhenConfigured(t *testing.T) { + defer stubGitConfig("Ada", "ada@ex.io")() + + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) + m.installed = map[string]bool{} + next, _ := m.Update(key("enter")) + m = next.(Model) + assert.Equal(t, scrInstall, m.screen, "configured git skips the capture screen") +} + +func TestGitScreenEscReturnsToSelect(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrGit, m.screen) + m = send(m, key("esc")) + assert.Equal(t, scrSelect, m.screen) +} + +// stubGitConfig swaps the git-identity lookup for tests and returns a restore. +func stubGitConfig(name, email string) func() { + prev := gitConfigLookup + gitConfigLookup = func() (string, string) { return name, email } + return func() { gitConfigLookup = prev } +} + func TestBuildPhases(t *testing.T) { plan := installer.InstallPlan{ Formulae: []string{"a", "b"}, @@ -216,6 +271,9 @@ func TestViewDimensions(t *testing.T) { cases["boot-probing"] = sized(W, H) cases["boot-loadouts"] = finishProbes(sized(W, H)) cases["select"] = send(finishProbes(sized(W, H)), key("2")) + gitCase := send(finishProbes(sized(W, H)), key("2")) + gitCase.screen = scrGit + cases["git"] = gitCase cases["install"] = installFrame(finishProbes(sized(W, H)), W, H) for name, m := range cases { From de1f0ac47d644fe55d4087012e83efc8f47f2fca Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Wed, 8 Jul 2026 20:47:33 +0800 Subject: [PATCH 07/12] fix(npm): preserve exact console print/increment order in streaming helper npmStepDone reordered Increment before PrintLine; the original console path printed the line first. Final output is identical either way, but match the original ordering exactly so the non-TUI path is unchanged. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/npm/streaming.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go index bb11889..d7acbe4 100644 --- a/internal/npm/streaming.go +++ b/internal/npm/streaming.go @@ -41,10 +41,11 @@ func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg string) { } return } - bar.Increment() + // Print then Increment, matching the original console ordering exactly. if ok { bar.PrintLine(" ✔ %s", name) } else { bar.PrintLine(" ✗ %s (%s)", name, errMsg) } + bar.Increment() } From 3482613dbbf23d972c30abcf4b30fe65096265e9 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Wed, 8 Jul 2026 20:56:45 +0800 Subject: [PATCH 08/12] test(e2e): smoke the install wizard on a real pty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the compiled binary under script(1) with a fresh HOME: wait for the boot probes to finish, quit with q from the loadout screen, and assert the wizard rendered, exited 0, and restored the terminal (alt-screen leave). Nothing is installed — boot probes are read-only and the test never confirms an install. This closes the gap the wizard unit tests can't reach: alt-screen entry/ exit, the global stdout redirect in wizard.Run, and clean teardown only manifest with a TTY attached. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- test/e2e/install_wizard_e2e_test.go | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/e2e/install_wizard_e2e_test.go diff --git a/test/e2e/install_wizard_e2e_test.go b/test/e2e/install_wizard_e2e_test.go new file mode 100644 index 0000000..10d0a94 --- /dev/null +++ b/test/e2e/install_wizard_e2e_test.go @@ -0,0 +1,116 @@ +//go:build e2e && !vm + +// E2E smoke test for the full-screen install wizard (TUI Redesign v5). +// +// Gap filled: the wizard's model logic is unit-tested in +// internal/ui/tui/wizard, but nothing exercised the compiled binary on a real +// pseudo-terminal — alt-screen entry/exit, the global stdout redirect in +// wizard.Run, and clean teardown only manifest with a TTY attached. This test +// drives the binary under script(1) (present on every macOS host), waits for +// the boot probes to finish, quits with 'q', and asserts the wizard rendered +// and restored the terminal. It never confirms an install, so nothing is +// installed; boot probes are read-only. +package e2e + +import ( + "io" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" +) + +// syncBuffer is a goroutine-safe writer the pty output streams into while the +// test polls it for render markers. +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +func TestE2E_InstallWizard_LaunchRenderQuit(t *testing.T) { + binary := testutil.BuildTestBinary(t) + home := t.TempDir() // fresh HOME: no sync source, no state → wizard branch + + // Dead localhost API: catalog refresh fails fast and falls back to the + // embedded catalog, keeping the test hermetic. + env := isolatedEnv(home, "http://localhost:1") + env = append(env, "TERM=xterm-256color") + + // script(1) allocates a pty; size it first so the wizard has a viewport + // (bubbletea renders nothing at 0x0). + cmd := exec.Command("script", "-q", "/dev/null", + "sh", "-c", "stty rows 30 cols 100; exec "+binary+" install") + cmd.Env = env + + stdin, err := cmd.StdinPipe() + require.NoError(t, err) + var out syncBuffer + cmd.Stdout = &out + cmd.Stderr = &out + + require.NoError(t, cmd.Start()) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + // Wait for the boot probes to complete (loadout list appears). Generous + // deadline: the installed-tools scan runs real `brew list` which can be + // slow on a cold CI runner. + waitFor := func(marker string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(out.String(), marker) { + return true + } + select { + case <-done: + return strings.Contains(out.String(), marker) + case <-time.After(200 * time.Millisecond): + } + } + return false + } + + require.True(t, waitFor("Choose a starting point", 90*time.Second), + "boot screen should reach the loadout list; output:\n%s", out.String()) + + // Quit from the loadout screen — never confirm an install. + _, err = io.WriteString(stdin, "q") + require.NoError(t, err) + + select { + case waitErr := <-done: + assert.NoError(t, waitErr, "binary should exit 0 after q") + case <-time.After(15 * time.Second): + _ = cmd.Process.Kill() + t.Fatalf("wizard did not exit within 15s of pressing q; output:\n%s", out.String()) + } + + got := out.String() + // Rendered the boot screen with real probe results and the preset list. + assert.Contains(t, got, "openboot install", "status bar rendered") + assert.Contains(t, got, "Minimal", "loadout list rendered") + assert.Contains(t, got, "Developer", "loadout list rendered") + // Entered and left the alternate screen — terminal restored. + assert.Contains(t, got, "\x1b[?1049h", "entered alt-screen") + assert.Contains(t, got, "\x1b[?1049l", "left alt-screen (terminal restored)") + // Fresh HOME must not route to the sync flow. + assert.NotContains(t, got, "Syncing with", "fresh HOME must take the wizard branch") +} From f71da90890360ce9e99848440d9b2710d60b5a9f Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Wed, 8 Jul 2026 21:25:50 +0800 Subject: [PATCH 09/12] fix(tui): accept multi-rune (pasted) input in filter and git fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bubbletea coalesces pasted or fast-arriving text into a single multi-rune KeyMsg; the filter and git-identity inputs only accepted single-character messages, so pasting a package name (or expect-driven input) was silently dropped. Accept any KeyRunes message in the two text-input contexts. Found by the pty choreography e2e test — the first "/stow\r" burst arrived as one coalesced key event and never reached the query. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/ui/tui/wizard/git.go | 4 +++- internal/ui/tui/wizard/select.go | 4 +++- internal/ui/tui/wizard/wizard_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/ui/tui/wizard/git.go b/internal/ui/tui/wizard/git.go index 1e3ec5c..1e16863 100644 --- a/internal/ui/tui/wizard/git.go +++ b/internal/ui/tui/wizard/git.go @@ -53,7 +53,9 @@ func (m Model) updateGit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.gitEmail = trimLast(m.gitEmail) } default: - if s := msg.String(); len(s) == 1 && s >= " " { + // KeyRunes covers both single keystrokes and multi-rune input + // (bubbletea coalesces pasted/fast text into one message). + if s := msg.String(); msg.Type == tea.KeyRunes || s == " " { if m.gitField == 0 { m.gitName += s } else { diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index 93c5722..9f6a6fc 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -121,7 +121,9 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy m.rowCur++ } default: - if len(s) == 1 && s >= " " { + // KeyRunes covers both single keystrokes and multi-rune input + // (bubbletea coalesces pasted/fast text into one message). + if msg.Type == tea.KeyRunes || s == " " { m.query += s m.rowCur, m.scroll = 0, 0 } diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 801e4d4..778e216 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -91,6 +91,31 @@ func TestSelectFilter(t *testing.T) { assert.Empty(t, m.query) } +// Bubbletea coalesces pasted/fast text into one multi-rune KeyMsg; the filter +// and git inputs must accept it, not drop it. +func TestSelectFilterAcceptsPastedText(t *testing.T) { + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + m = send(m, key("/")) + require.True(t, m.typing) + m = send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("zoxide")}) + assert.Equal(t, "zoxide", m.query, "multi-rune input appends to the query") +} + +func TestGitFieldsAcceptPastedText(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrGit, m.screen) + m = send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("CI Bot")}) + m = send(m, key("tab")) + m = send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ci@example.com")}) + assert.Equal(t, "CI Bot", m.gitName) + assert.Equal(t, "ci@example.com", m.gitEmail) +} + func TestSelectCategoryCycle(t *testing.T) { m := finishProbes(sized(96, 30)) m = send(m, key("c")) From 3a505d5ed7c4bff2241d79e02808310542913bfb Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Wed, 8 Jul 2026 21:25:50 +0800 Subject: [PATCH 10/12] test(e2e): wizard keyboard choreography (L3) + real TUI install on VM (L4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the last coverage gap — "a real install driven through the TUI" — following the lazygit integration-test pattern: drive the real binary on a pty, wait for on-screen markers (never fixed sleeps), then assert real system state. - L3 (runs locally / at release, installs nothing): FullChoreography walks boot → hand-pick → filter → toggle → confirm → git identity with every keystroke of the real path, quitting right before the final confirm. - L4 (macos-14 VM, every PR): TestVM_WizardTUI_RealInstall replays the same key sequence through a real install via MacHost.RunInteractive (expect), then asserts brew actually installed the formula, the captured git identity was written, and oh-my-zsh landed in the isolated HOME. - RunInteractive now paces sends one char per 20ms (expect send -s): bubbletea coalesces byte bursts into one key event, which drops multi-character sends. - Git config is isolated via GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM so the identity screen appears deterministically and assertions don't touch the host's real config. HARNESS.md gains a row for the new behaviour sensors. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- docs/HARNESS.md | 1 + test/e2e/install_wizard_e2e_test.go | 178 +++++++++++++++++++------ test/e2e/install_wizard_shared_test.go | 52 ++++++++ test/e2e/install_wizard_vm_test.go | 92 +++++++++++++ testutil/machost.go | 7 +- 5 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 test/e2e/install_wizard_shared_test.go create mode 100644 test/e2e/install_wizard_vm_test.go diff --git a/docs/HARNESS.md b/docs/HARNESS.md index e46d1d9..1bf392a 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -51,6 +51,7 @@ Three regulation categories: | Behav. | L2 contract schema (against openboot-contract repo) | CI | `.github/workflows/test.yml` `contract` job | | Behav. | L3 e2e binary | release | `make test-e2e` | | Behav. | L4 VM e2e (`vm`) — full destructive suite on a clean macOS host | every PR | `.github/workflows/vm-e2e-spike.yml` (macos-14 runner, two parallel jobs) | +| Behav. | Install-wizard TUI on a real pty — L3: launch/quit smoke + full keyboard choreography (stops before confirm, installs nothing); L4: same key sequence through a real install via `expect(1)`, asserting brew/git system state | L3 at release, L4 every PR | `test/e2e/install_wizard_e2e_test.go`, `test/e2e/install_wizard_vm_test.go` | | Behav. | curl\|bash smoke (install.sh + mock server) | every PR | `.github/workflows/test.yml` `curl-bash-smoke` job | | Behav. | Auto-release sensor — patch fast lane (`fix:`-only) auto-tags + dispatches `release.yml`; feat threshold opens a `release-ready` issue (check L4 CI green, then tag manually) | push to `main` | `.github/workflows/auto-release.yml` | | Behav. | Release notes — Conventional Commits since previous tag, grouped by type (Features / Bug Fixes / etc) + Full Changelog link, appended to the install-instructions template | tag push or `workflow_dispatch` | `.github/workflows/release.yml` (`Write release notes` step) | diff --git a/test/e2e/install_wizard_e2e_test.go b/test/e2e/install_wizard_e2e_test.go index 10d0a94..6546365 100644 --- a/test/e2e/install_wizard_e2e_test.go +++ b/test/e2e/install_wizard_e2e_test.go @@ -1,15 +1,20 @@ //go:build e2e && !vm -// E2E smoke test for the full-screen install wizard (TUI Redesign v5). +// E2E tests for the full-screen install wizard (TUI Redesign v5), driven on a +// real pseudo-terminal via script(1) (present on every macOS host). // // Gap filled: the wizard's model logic is unit-tested in // internal/ui/tui/wizard, but nothing exercised the compiled binary on a real -// pseudo-terminal — alt-screen entry/exit, the global stdout redirect in -// wizard.Run, and clean teardown only manifest with a TTY attached. This test -// drives the binary under script(1) (present on every macOS host), waits for -// the boot probes to finish, quits with 'q', and asserts the wizard rendered -// and restored the terminal. It never confirms an install, so nothing is -// installed; boot probes are read-only. +// pty — alt-screen entry/exit, the global stdout redirect in wizard.Run, and +// clean teardown only manifest with a TTY attached. Following the lazygit +// integration-test discipline, every step waits for an on-screen marker before +// sending keys — no fixed sleeps. +// +// These tests never confirm an install: the smoke test quits from the loadout +// screen, and the choreography test walks boot → select → filter → toggle → +// git identity and quits right before the final confirm. Boot probes are +// read-only. The destructive counterpart that lets the install run for real +// lives in install_wizard_vm_test.go (L4, CI only). package e2e import ( @@ -45,14 +50,17 @@ func (s *syncBuffer) String() string { return s.b.String() } -func TestE2E_InstallWizard_LaunchRenderQuit(t *testing.T) { - binary := testutil.BuildTestBinary(t) - home := t.TempDir() // fresh HOME: no sync source, no state → wizard branch +// wizardSession is a live wizard process on a pty. +type wizardSession struct { + stdin io.WriteCloser + out *syncBuffer + done chan error + cmd *exec.Cmd +} - // Dead localhost API: catalog refresh fails fast and falls back to the - // embedded catalog, keeping the test hermetic. - env := isolatedEnv(home, "http://localhost:1") - env = append(env, "TERM=xterm-256color") +// startWizardPty launches ` install` under script(1) with a sized pty. +func startWizardPty(t *testing.T, binary string, env []string) *wizardSession { + t.Helper() // script(1) allocates a pty; size it first so the wizard has a viewport // (bubbletea renders nothing at 0x0). @@ -62,49 +70,92 @@ func TestE2E_InstallWizard_LaunchRenderQuit(t *testing.T) { stdin, err := cmd.StdinPipe() require.NoError(t, err) - var out syncBuffer - cmd.Stdout = &out - cmd.Stderr = &out + out := &syncBuffer{} + cmd.Stdout = out + cmd.Stderr = out require.NoError(t, cmd.Start()) done := make(chan error, 1) go func() { done <- cmd.Wait() }() - // Wait for the boot probes to complete (loadout list appears). Generous - // deadline: the installed-tools scan runs real `brew list` which can be - // slow on a cold CI runner. - waitFor := func(marker string, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if strings.Contains(out.String(), marker) { - return true - } - select { - case <-done: - return strings.Contains(out.String(), marker) - case <-time.After(200 * time.Millisecond): - } + return &wizardSession{stdin: stdin, out: out, done: done, cmd: cmd} +} + +// waitFor polls the pty output until marker appears. Markers must lie within a +// single styled span so ANSI codes don't split them. +func (s *wizardSession) waitFor(marker string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(s.out.String(), marker) { + return true + } + select { + case <-s.done: + return strings.Contains(s.out.String(), marker) + case <-time.After(200 * time.Millisecond): } - return false } + return false +} - require.True(t, waitFor("Choose a starting point", 90*time.Second), - "boot screen should reach the loadout list; output:\n%s", out.String()) - - // Quit from the loadout screen — never confirm an install. - _, err = io.WriteString(stdin, "q") +func (s *wizardSession) send(t *testing.T, keys string) { + t.Helper() + _, err := io.WriteString(s.stdin, keys) require.NoError(t, err) +} + +// sendPaced writes each segment as its own keystroke burst with a small gap in +// between, so mode-switching keys ("/", tab) land as their own key events +// instead of being coalesced with the following text. The gaps pace input; +// state waiting still goes through waitFor markers. +func (s *wizardSession) sendPaced(t *testing.T, segments ...string) { + t.Helper() + for _, seg := range segments { + s.send(t, seg) + time.Sleep(50 * time.Millisecond) + } +} +// expectExit waits for the process to end, killing it on timeout. +func (s *wizardSession) expectExit(t *testing.T, timeout time.Duration) { + t.Helper() select { - case waitErr := <-done: - assert.NoError(t, waitErr, "binary should exit 0 after q") - case <-time.After(15 * time.Second): - _ = cmd.Process.Kill() - t.Fatalf("wizard did not exit within 15s of pressing q; output:\n%s", out.String()) + case waitErr := <-s.done: + assert.NoError(t, waitErr, "binary should exit cleanly") + case <-time.After(timeout): + _ = s.cmd.Process.Kill() + t.Fatalf("wizard did not exit within %s; output:\n%s", timeout, s.out.String()) } +} + +// wizardEnv builds the isolated environment for a wizard run: fresh HOME (no +// sync source → wizard branch), git config redirected to a throwaway file so +// the git-identity screen deterministically appears, dead localhost API so the +// catalog falls back to the embedded copy, and a fixed TERM. +func wizardEnv(home string) []string { + env := isolatedEnv(home, "http://localhost:1") + return append(env, + "TERM=xterm-256color", + "GIT_CONFIG_GLOBAL="+home+"/gitconfig-test", + "GIT_CONFIG_SYSTEM=/dev/null", + ) +} + +// TestE2E_InstallWizard_LaunchRenderQuit: boot screen renders with real probe +// results, q quits from the loadout list, terminal is restored. +func TestE2E_InstallWizard_LaunchRenderQuit(t *testing.T) { + binary := testutil.BuildTestBinary(t) + s := startWizardPty(t, binary, wizardEnv(t.TempDir())) + + // Generous deadline: the installed-tools scan runs real `brew list`, + // which can be slow on a cold CI runner. + require.True(t, s.waitFor("Choose a starting point", 90*time.Second), + "boot screen should reach the loadout list; output:\n%s", s.out.String()) - got := out.String() - // Rendered the boot screen with real probe results and the preset list. + s.send(t, "q") + s.expectExit(t, 15*time.Second) + + got := s.out.String() assert.Contains(t, got, "openboot install", "status bar rendered") assert.Contains(t, got, "Minimal", "loadout list rendered") assert.Contains(t, got, "Developer", "loadout list rendered") @@ -114,3 +165,42 @@ func TestE2E_InstallWizard_LaunchRenderQuit(t *testing.T) { // Fresh HOME must not route to the sync flow. assert.NotContains(t, got, "Syncing with", "fresh HOME must take the wizard branch") } + +// TestE2E_InstallWizard_FullChoreography drives every keystroke of the real +// install path — hand-pick, filter, toggle, confirm, git identity — and quits +// with ctrl+c right before the final confirm, so nothing is installed. This +// pins the exact key sequence the L4 real-install test replays destructively. +func TestE2E_InstallWizard_FullChoreography(t *testing.T) { + formula := wizardTestFormula(t) + t.Logf("driving selection with formula %q", formula) + + binary := testutil.BuildTestBinary(t) + s := startWizardPty(t, binary, wizardEnv(t.TempDir())) + + require.True(t, s.waitFor("Choose a starting point", 90*time.Second), + "boot: loadout list; output:\n%s", s.out.String()) + s.send(t, "c") // hand-pick: empty selection + + require.True(t, s.waitFor("type to filter", 10*time.Second), + "select: filter placeholder; output:\n%s", s.out.String()) + s.sendPaced(t, "/", formula, "\r") // filter, enter toggles the single hit + + require.True(t, s.waitFor("1 pkgs", 10*time.Second), + "status bar should show 1 package selected; output:\n%s", s.out.String()) + s.send(t, "\r") // proceed → git screen (no identity in isolated config) + + require.True(t, s.waitFor("Set your git identity", 10*time.Second), + "git capture screen; output:\n%s", s.out.String()) + s.sendPaced(t, "CI Bot", "\t", "ci@example.com") + + require.True(t, s.waitFor("ci@example.com", 10*time.Second), + "email field rendered; output:\n%s", s.out.String()) + + // Stop here — the next enter would start a real install (L4's job). + s.send(t, "\x03") + s.expectExit(t, 15*time.Second) + + got := s.out.String() + assert.Contains(t, got, "GIT", "git screen status badge rendered") + assert.Contains(t, got, "\x1b[?1049l", "terminal restored") +} diff --git a/test/e2e/install_wizard_shared_test.go b/test/e2e/install_wizard_shared_test.go new file mode 100644 index 0000000..547c4e5 --- /dev/null +++ b/test/e2e/install_wizard_shared_test.go @@ -0,0 +1,52 @@ +//go:build e2e + +// Helpers shared by the wizard TUI tests in both build flavors: the +// non-destructive choreography tests (e2e && !vm) and the destructive +// real-install test (e2e && vm). +package e2e + +import ( + "os/exec" + "strings" + "testing" + + "github.com/openbootdotdev/openboot/internal/config" +) + +// wizardFormulaCandidates are small catalog formulae whose names match exactly +// one catalog row by substring, so filtering the select screen by name yields +// a deterministic top hit for "enter toggles the top hit". +var wizardFormulaCandidates = []string{"stow", "zoxide", "tealdeer"} + +// wizardTestFormula picks a candidate formula not currently installed via +// Homebrew. Skips the test when brew is unavailable or every candidate is +// already present (the choreography needs a toggleable row). +func wizardTestFormula(t *testing.T) string { + t.Helper() + out, err := exec.Command("brew", "list", "--formula").Output() + if err != nil { + t.Skipf("brew not available: %v", err) + } + installed := map[string]bool{} + for _, name := range strings.Fields(string(out)) { + installed[name] = true + } + for _, name := range wizardFormulaCandidates { + if catalogHasFormula(name) && !installed[name] { + return name + } + } + t.Skipf("all candidate formulae already installed: %v", wizardFormulaCandidates) + return "" +} + +func catalogHasFormula(name string) bool { + for _, cat := range config.GetCategories() { + for _, p := range cat.Packages { + if p.Name == name && !p.IsCask && !p.IsNpm { + return true + } + } + } + return false +} diff --git a/test/e2e/install_wizard_vm_test.go b/test/e2e/install_wizard_vm_test.go new file mode 100644 index 0000000..a3da79c --- /dev/null +++ b/test/e2e/install_wizard_vm_test.go @@ -0,0 +1,92 @@ +//go:build e2e && vm + +package e2e + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" +) + +// TestVM_WizardTUI_RealInstall drives the full-screen install wizard through a +// REAL install on the CI macOS runner: boot → hand-pick → filter → toggle → +// git identity → live streaming install → DONE screen, then asserts the real +// system state (brew package present, git identity written). +// +// Gap filled: the wizard's streaming path (progress sink + real brew) is the +// one seam no other tier reaches — unit tests fake the engine, the L3 +// choreography test (install_wizard_e2e_test.go) stops right before the final +// confirm, and the other L4 tests install via the silent (non-TUI) path. The +// key sequence here mirrors TestE2E_InstallWizard_FullChoreography exactly; +// keep the two in sync. +func TestVM_WizardTUI_RealInstall(t *testing.T) { + if testing.Short() { + t.Skip("skipping VM wizard test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + + // expect(1) drives the TUI; install it if the runner lacks it. + if _, err := vm.Run(fmt.Sprintf("export PATH=%q && command -v expect", brewPath)); err != nil { + out, installErr := vm.Run(fmt.Sprintf("export PATH=%q && brew install expect", brewPath)) + t.Logf("install expect: %s", out) + require.NoError(t, installErr, "should install expect for interactive tests") + } + + binary := testutil.BuildTestBinary(t) + home := t.TempDir() // fresh HOME → wizard branch; shell/dotfiles land here + gitCfg := home + "/gitconfig-test" + + // Deterministic target: make sure the formula is absent before the run. + const formula = "stow" + vm.Run(fmt.Sprintf("export PATH=%q && brew uninstall --ignore-dependencies %s", brewPath, formula)) //nolint:errcheck // absent is fine + + // Isolated env: dead localhost API → embedded catalog; git config + // redirected so the identity screen deterministically appears and the + // written identity is assertable without touching the runner's config. + cmd := fmt.Sprintf( + "export HOME=%q GIT_CONFIG_GLOBAL=%q GIT_CONFIG_SYSTEM=/dev/null"+ + " OPENBOOT_DISABLE_AUTOUPDATE=1 OPENBOOT_API_URL=http://localhost:1"+ + " TERM=xterm-256color PATH=%q && stty rows 32 cols 110 && %s install", + home, gitCfg, brewPath, binary, + ) + + // Every Expect marker lies within a single styled span, so ANSI codes + // can't split it. "snapshot publish" is the DONE-screen next-step hint — + // it renders on both clean and soft-error completion, so the expect + // script can't hang; success is asserted on "dev-ready" below. + output, err := vm.RunInteractive(cmd, []testutil.ExpectStep{ + {Expect: "Choose a starting point", Send: "c"}, + {Expect: "type to filter", Send: "/" + formula + "\r"}, + {Expect: "1 pkgs", Send: "\r"}, + {Expect: "Set your git identity", Send: "CI Bot\tci@openboot.test\r"}, + {Expect: "snapshot publish", Send: "q"}, + }, 900) + t.Logf("wizard session:\n%s", output) + if err != nil { + t.Logf("expect exited with: %v", err) + } + + // The DONE screen reported a clean install (soft errors render + // "Finished with some errors" instead). + assert.Contains(t, output, "dev-ready", "install should finish clean") + + // Real system state: the package actually installed… + listOut, listErr := vm.Run(fmt.Sprintf("export PATH=%q && brew list --formula %s", brewPath, formula)) + assert.NoError(t, listErr, "%s should be installed via brew: %s", formula, listOut) + + // …and the captured git identity actually written. + nameOut, nameErr := vm.Run(fmt.Sprintf("export GIT_CONFIG_GLOBAL=%q GIT_CONFIG_SYSTEM=/dev/null && git config --global user.name", gitCfg)) + assert.NoError(t, nameErr, "git identity should be configured") + assert.Equal(t, "CI Bot", strings.TrimSpace(nameOut), "git user.name from the TUI capture") + + // System-config steps ran against the isolated HOME. + _, omzErr := vm.Run(fmt.Sprintf("test -d %q", home+"/.oh-my-zsh")) + assert.NoError(t, omzErr, "oh-my-zsh should be installed into the wizard's HOME") +} diff --git a/testutil/machost.go b/testutil/machost.go index c40e502..24cd085 100644 --- a/testutil/machost.go +++ b/testutil/machost.go @@ -73,13 +73,18 @@ func (h *MacHost) RunInteractive(command string, steps []ExpectStep, timeoutSec var script strings.Builder fmt.Fprintf(&script, "set timeout %d\n", timeoutSec) + // Pace keystrokes one character per 20ms. Full-screen TUIs (bubbletea) + // coalesce a burst of bytes arriving in one read into a single key event, + // which drops multi-character sends like "/stow\r"; slow sends make each + // character its own event, like a human typing. + script.WriteString("set send_slow {1 .02}\n") // Use Tcl quoting so the entire command is a single word. // shellescape produces POSIX single-quote escaping which Tcl's word // splitter does not honour — it splits on whitespace regardless. fmt.Fprintf(&script, "spawn bash -c %s\n", tclBrace(command)) for _, step := range steps { fmt.Fprintf(&script, "expect %q\n", step.Expect) - fmt.Fprintf(&script, "send %q\n", step.Send) + fmt.Fprintf(&script, "send -s %q\n", step.Send) } script.WriteString("expect eof\n") From 5689218f5e108c1de791e5843564aee8c94ae49f Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 9 Jul 2026 01:28:22 +0800 Subject: [PATCH 11/12] fix(tui): address lint findings from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exhaustive: cover rHeader/rInfo/rMuted explicitly in reporterLogLine (intentional drops — headers drive phase activation, narration would drown the log) - gocyclo: extract shouldLaunchWizard so runInstallCmd stays under the complexity ceiling - gosec G118: annotate the stored cancel func (called on ctrl+c and install completion; gosec can't see across the model) Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/cli/install.go | 14 +++++++++----- internal/ui/tui/wizard/install.go | 6 +++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/cli/install.go b/internal/cli/install.go index 976d9f3..81b1514 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -118,11 +118,7 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("apply install source: %w", err) } - // Bare interactive `openboot install` on a TTY runs the full-screen - // install wizard (boot probe → select → live install). Explicit - // sources (-p, --from, -u, sync), --silent, and --dry-run keep their - // existing flows. - if src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && system.HasTTY() { + if shouldLaunchWizard(src) { return runInstallWizard() } } @@ -160,6 +156,14 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { return err } +// shouldLaunchWizard reports whether this run is a bare interactive +// `openboot install` on a TTY — the case the full-screen wizard (boot probe → +// select → live install) owns. Explicit sources (-p, --from, -u, sync), +// --silent, and --dry-run keep their existing flows. +func shouldLaunchWizard(src *installSource) bool { + return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && system.HasTTY() +} + // runInstallWizard launches the full-screen install TUI and runs the resulting // install. The wizard owns the whole interactive flow (planning + apply), so // there is nothing further to do here on success. diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index bb49f2c..ea5f539 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -87,7 +87,7 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { m.confirmed = true m.installTick = m.ticks - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored on the model and called on ctrl+c and install completion m.cancel = cancel return m, tea.Batch(m.spawnInstall(ctx, plan), waitForEvent(m.events)) @@ -287,6 +287,10 @@ func reporterLogLine(t reporterMsg) (logLine, bool) { return logLine{mark: "!", markColor: cWarn, text: text, color: cWarn}, true case rError: return logLine{mark: "✗", markColor: cDanger, text: text, color: cDanger}, true + case rHeader, rInfo, rMuted: + // Dropped intentionally — headers drive phase activation (see + // headerPhase) and info/muted narration would drown the log. + return logLine{}, false } return logLine{}, false } From d105dd3746ce8b457cc7af090eaddcc7df864ceb Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 9 Jul 2026 08:53:37 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(tui):=20address=20deep-review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20consent,=20abort,=20routing,=20accounting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten confirmed findings from the multi-agent review of PR #134, fixed as a batch: Consent (user decision: pre-flight review screen, defaults on): - New confirm screen between select/git and install: shows packages, git identity, and toggleable rows for oh-my-zsh / dotfiles / macOS prefs. Nothing mutates the system without having been on screen; toggles gate the plan in startInstall. CLI routing: - shouldLaunchWizard now excludes --update (runUpdate was unreachable interactively) and --pick falls through to its existing error instead of being silently dropped. Abort semantics: - First ctrl+c during install requests an abort: cancels the context and keeps the TUI until the engine reports done (goroutine joined, stdout redirect not restored under its feet); second ctrl+c force-quits. Aborted runs return ErrAborted → CLI exits non-zero with a message. - On error/abort, unfinished phases stay visibly unfinished instead of being check-marked. Input correctness: - Backspace trims one rune, not one byte (git fields + filter query) — multi-byte names no longer corrupt into invalid UTF-8 in git config. TTY contention: - In streaming mode brew no longer wires /dev/tty into cask installs; a sudo prompt fails fast as a visible step error instead of hanging the raw-mode TUI indefinitely. Parity: - Screen-recording reminder runs after the wizard exits, back on a normal terminal (installer.ShowScreenRecordingReminderAfterTUI). Progress accounting — new streaming invariant: every planned package produces exactly one terminal event (installed / failed / already- installed skip): - brew and npm emit skip events for already-installed and alias-resolved packages; installer state-file skips emit via brew/npm.EmitSkipped. - npm partial-batch recoveries emit their StepOK. - brew retry passes emit events (log no longer shows ✗ for packages that succeeded on retry); phase counters clamp at total. - buildPhases counts the full plan; DONE footer counts actual installs. E2e choreography (L3 + L4) updated for the confirm screen. Claude-Session: https://claude.ai/code/session_01HLoiSd2k9EJJ1HPjjDgUgo --- internal/archtest/baseline/no-direct-exec.txt | 2 +- internal/brew/brew_install.go | 45 +++++- internal/brew/streaming.go | 17 ++ internal/cli/install.go | 24 ++- internal/installer/installer.go | 10 ++ internal/installer/step_packages.go | 17 +- internal/npm/npm.go | 21 ++- internal/npm/streaming.go | 12 ++ internal/progress/progress.go | 6 + internal/ui/tui/wizard/confirm.go | 152 ++++++++++++++++++ internal/ui/tui/wizard/frames_test.go | 15 +- internal/ui/tui/wizard/git.go | 8 +- internal/ui/tui/wizard/install.go | 76 ++++++--- internal/ui/tui/wizard/select.go | 7 +- internal/ui/tui/wizard/status.go | 8 + internal/ui/tui/wizard/wizard.go | 50 +++++- internal/ui/tui/wizard/wizard_test.go | 128 +++++++++++++-- test/e2e/install_wizard_e2e_test.go | 5 + test/e2e/install_wizard_vm_test.go | 1 + 19 files changed, 531 insertions(+), 73 deletions(-) create mode 100644 internal/ui/tui/wizard/confirm.go diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 60ae41f..d8965ae 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -2,7 +2,7 @@ # Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... internal/auth/login.go:195 -internal/brew/brew_install.go:334 +internal/brew/brew_install.go:356 internal/cli/snapshot.go:22 internal/diff/compare.go:247 internal/diff/compare.go:253 diff --git a/internal/brew/brew_install.go b/internal/brew/brew_install.go index e304b48..29a3aa5 100644 --- a/internal/brew/brew_install.go +++ b/internal/brew/brew_install.go @@ -110,23 +110,27 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun // Casks don't have an alias system, so we skip resolution for them. aliasMap := ResolveFormulaNames(cliPkgs) - var newCli []string + var newCli, skippedCli []string for _, p := range cliPkgs { resolvedName := aliasMap[p] if !alreadyFormulae[resolvedName] { newCli = append(newCli, p) } else { installedFormulae = append(installedFormulae, resolvedName) + skippedCli = append(skippedCli, p) } } - var newCask []string + var newCask, skippedCask []string for _, p := range caskPkgs { if !alreadyCasks[p] { newCask = append(newCask, p) } else { installedCasks = append(installedCasks, p) + skippedCask = append(skippedCask, p) } } + // Streaming invariant: skipped packages still produce a terminal event. + EmitSkipped(skippedCli, skippedCask) skipped := total - len(newCli) - len(newCask) if skipped > 0 { @@ -228,6 +232,16 @@ func retryFailedJobs(ctx context.Context, allFailed []failedJob, installedFormul ui.Printf("\nRetrying %d failed packages...\n", len(allFailed)) for _, f := range allFailed { + phase := progresspkg.PhaseHomebrew + if f.isCask { + phase = progresspkg.PhaseApplications + } + // Streaming: the retry outcome supersedes the earlier StepFail in the + // log; without these events the wizard would show ✗ for a package + // that actually installed on retry. + if streaming() { + progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepStart, Command: "retrying " + f.name}) + } var errMsg string if f.isCask { errMsg = installSmartCaskWithError(ctx, f.name) @@ -235,14 +249,22 @@ func retryFailedJobs(ctx context.Context, allFailed []failedJob, installedFormul errMsg = installFormulaWithError(ctx, f.name) } if errMsg == "" { - ui.Printf(" ✔ %s (retry succeeded)\n", f.name) + if streaming() { + progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepOK, Detail: "retry succeeded"}) + } else { + ui.Printf(" ✔ %s (retry succeeded)\n", f.name) + } if f.isCask { *installedCasks = append(*installedCasks, f.name) } else { *installedFormulae = append(*installedFormulae, aliasMap[f.name]) } } else { - ui.Printf(" ✗ %s (still failed)\n", f.name) + if streaming() { + progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepFail, Detail: "still failed: " + errMsg}) + } else { + ui.Printf(" ✗ %s (still failed)\n", f.name) + } } } @@ -338,12 +360,19 @@ func brewInstallCmd(ctx context.Context, args ...string) *exec.Cmd { // brewCombinedOutputWithTTY runs a brew command capturing combined output while // providing a TTY for stdin so that sudo password prompts work. +// +// In streaming mode the TUI owns the terminal in raw mode: a sudo prompt would +// be invisible and its keystrokes swallowed by the TUI's input reader, hanging +// the install. Withholding the TTY makes sudo fail fast instead, surfacing a +// visible step failure. func brewCombinedOutputWithTTY(ctx context.Context, args ...string) (string, error) { cmd := brewInstallCmd(ctx, args...) - tty, opened := system.OpenTTY() - if opened { - cmd.Stdin = tty - defer tty.Close() //nolint:errcheck // best-effort TTY cleanup + if !streaming() { + tty, opened := system.OpenTTY() + if opened { + cmd.Stdin = tty + defer tty.Close() //nolint:errcheck // best-effort TTY cleanup + } } output, err := cmd.CombinedOutput() return string(output), err diff --git a/internal/brew/streaming.go b/internal/brew/streaming.go index b6c02e3..369048d 100644 --- a/internal/brew/streaming.go +++ b/internal/brew/streaming.go @@ -22,6 +22,23 @@ func SetProgressSink(s progress.Sink) (restore func()) { // streaming reports whether a sink is registered (TUI mode). func streaming() bool { return progressSink != nil } +// EmitSkipped emits an already-installed StepOK event for each named package, +// upholding the streaming invariant that every planned package produces +// exactly one terminal event. Callers that filter packages before reaching +// the install loops (state-file skips, alias-resolved skips) use this so the +// renderer's totals still reconcile. No-op when no sink is registered. +func EmitSkipped(formulae, casks []string) { + if !streaming() { + return + } + for _, n := range formulae { + progressSink.Emit(progress.Event{Phase: progress.PhaseHomebrew, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) + } + for _, n := range casks { + progressSink.Emit(progress.Event{Phase: progress.PhaseApplications, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) + } +} + // stepStart reports the beginning of a package install: emits a StepStart event // when streaming, otherwise advances the sticky progress bar. func stepStart(bar *ui.StickyProgress, phase, name, command string) { diff --git a/internal/cli/install.go b/internal/cli/install.go index 81b1514..06f4917 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -118,7 +118,10 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("apply install source: %w", err) } - if shouldLaunchWizard(src) { + // --pick has no meaning in the wizard (it filters remote configs); + // let it fall through to the existing "--pick requires a remote + // config" error instead of silently dropping it. + if pickRaw, _ := cmd.Flags().GetString("pick"); pickRaw == "" && shouldLaunchWizard(src) { return runInstallWizard() } } @@ -159,19 +162,28 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { // shouldLaunchWizard reports whether this run is a bare interactive // `openboot install` on a TTY — the case the full-screen wizard (boot probe → // select → live install) owns. Explicit sources (-p, --from, -u, sync), -// --silent, and --dry-run keep their existing flows. +// --silent, --dry-run, and --update keep their existing flows. func shouldLaunchWizard(src *installSource) bool { - return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && system.HasTTY() + return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && + !installCfg.Update && system.HasTTY() } // runInstallWizard launches the full-screen install TUI and runs the resulting -// install. The wizard owns the whole interactive flow (planning + apply), so -// there is nothing further to do here on success. +// install. The wizard owns the whole interactive flow (planning + apply); back +// on the normal terminal, follow-ups that can't run inside the alt-screen +// (screen-recording reminder) happen here. func runInstallWizard() error { opts := installCfg.ToInstallOptions() - if _, err := wizard.Run(installCfg.Version, opts); err != nil { + plan, confirmed, err := wizard.Run(installCfg.Version, opts) + if err != nil { + if errors.Is(err, wizard.ErrAborted) { + return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") + } return fmt.Errorf("install wizard: %w", err) } + if confirmed { + installer.ShowScreenRecordingReminderAfterTUI(plan) + } return nil } diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 1968051..0022fc6 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -171,6 +171,16 @@ func showCompletionFromPlan(plan InstallPlan, r Reporter, errCount int) { ui.Println() } +// ShowScreenRecordingReminderAfterTUI re-runs the screen-recording permission +// reminder for a plan applied by the full-screen wizard. The wizard forces +// plan.Silent=true to keep prompts out of the alt-screen, which also +// suppresses this reminder; the CLI calls this after the TUI exits, back on a +// normal terminal. +func ShowScreenRecordingReminderAfterTUI(plan InstallPlan) { + plan.Silent = false + showScreenRecordingReminderFromPlan(plan) +} + func showScreenRecordingReminderFromPlan(plan InstallPlan) { if plan.DryRun || plan.Silent { return diff --git a/internal/installer/step_packages.go b/internal/installer/step_packages.go index 0682574..48986b6 100644 --- a/internal/installer/step_packages.go +++ b/internal/installer/step_packages.go @@ -119,18 +119,25 @@ func applyPackages(ctx context.Context, plan InstallPlan, r Reporter) error { // } } + var stateSkippedCli, stateSkippedCask []string for _, pkg := range cliPkgs { if !state.isFormulaInstalled(pkg) { newCli = append(newCli, pkg) + } else { + stateSkippedCli = append(stateSkippedCli, pkg) } } for _, pkg := range caskPkgs { if !state.isCaskInstalled(pkg) { newCask = append(newCask, pkg) + } else { + stateSkippedCask = append(stateSkippedCask, pkg) } } + // Streaming invariant: state-file skips still produce terminal events. + brew.EmitSkipped(stateSkippedCli, stateSkippedCask) - stateSkipped := (len(cliPkgs) - len(newCli)) + (len(caskPkgs) - len(newCask)) + stateSkipped := len(stateSkippedCli) + len(stateSkippedCask) if stateSkipped > 0 { r.Muted(fmt.Sprintf("Skipping %d packages from previous install", stateSkipped)) } @@ -206,12 +213,18 @@ func applyNpm(ctx context.Context, plan InstallPlan, r Reporter) error { //nolin } } + var stateSkippedNpm []string for _, pkg := range npmPkgs { if !state.isNpmInstalled(pkg) { newNpm = append(newNpm, pkg) + } else { + stateSkippedNpm = append(stateSkippedNpm, pkg) } } - stateSkipped := len(npmPkgs) - len(newNpm) + // Streaming invariant: state-file skips still produce terminal events. + npm.EmitSkipped(stateSkippedNpm) + + stateSkipped := len(stateSkippedNpm) if stateSkipped > 0 { r.Muted(fmt.Sprintf("Skipping %d npm packages from previous install", stateSkipped)) } diff --git a/internal/npm/npm.go b/internal/npm/npm.go index e571a3a..0b489fc 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -109,12 +109,16 @@ func InstallContext(ctx context.Context, packages []string, dryRun bool) error { return fmt.Errorf("list installed packages: %w", err) } - var toInstall []string + var toInstall, alreadyInstalled []string for _, p := range packages { if !installed[p] { toInstall = append(toInstall, p) + } else { + alreadyInstalled = append(alreadyInstalled, p) } } + // Streaming invariant: skipped packages still produce a terminal event. + EmitSkipped(alreadyInstalled) skipped := len(packages) - len(toInstall) if skipped > 0 { @@ -214,15 +218,26 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [ return nil, fmt.Errorf("list packages after batch: %w", err) } - var remaining []string + var remaining, batchRecovered []string for _, pkg := range toInstall { if !nowInstalled[pkg] { remaining = append(remaining, pkg) + } else { + batchRecovered = append(batchRecovered, pkg) + } + } + // Packages the failed batch did manage to install must still produce + // their terminal event, or a streaming renderer's totals never complete. + if streaming() { + for _, pkg := range batchRecovered { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: pkg, Status: progress.StepOK}) } } if len(remaining) == 0 { - ui.Success("All npm packages already installed after partial batch!") + if !streaming() { + ui.Success("All npm packages already installed after partial batch!") + } return nil, nil } diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go index d7acbe4..52a40c1 100644 --- a/internal/npm/streaming.go +++ b/internal/npm/streaming.go @@ -21,6 +21,18 @@ func SetProgressSink(s progress.Sink) (restore func()) { // streaming reports whether a sink is registered (TUI mode). func streaming() bool { return progressSink != nil } +// EmitSkipped emits an already-installed StepOK event for each named package, +// upholding the streaming invariant that every planned package produces +// exactly one terminal event. No-op when no sink is registered. +func EmitSkipped(names []string) { + if !streaming() { + return + } + for _, n := range names { + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) + } +} + // npmStepStart reports the start of a single npm package install. func npmStepStart(bar *ui.StickyProgress, name string) { if streaming() { diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 67e86ed..9b93a1f 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -38,6 +38,12 @@ const ( PhaseNpm = "npm globals" ) +// SkipDetail marks a StepOK event for a package that needed no work because it +// was already installed. The invariant the engine upholds in streaming mode: +// every planned package produces exactly one terminal event — StepOK (installed +// or SkipDetail) or StepFail — so a renderer's totals always reconcile. +const SkipDetail = "already installed" + // Sink receives install progress events. A nil Sink means "no streaming // renderer registered" — the engine falls back to its own progress output. type Sink func(Event) diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go new file mode 100644 index 0000000..a8e5c44 --- /dev/null +++ b/internal/ui/tui/wizard/confirm.go @@ -0,0 +1,152 @@ +package wizard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/openbootdotdev/openboot/internal/installer" +) + +// The confirm screen is the last stop before the engine runs: it shows exactly +// what this run will do — packages, git identity, and the three system-config +// steps as toggleable rows (default on, preserving the design's defaults-on +// spirit) — so nothing mutates the system without having been on screen. + +// confirmRow identifies one toggleable system-config row. +type confirmRow int + +const ( + rowShell confirmRow = iota + rowDotfiles + rowPrefs +) + +// confirmRows returns the toggleable rows present for the previewed plan. +func (m Model) confirmRows() []confirmRow { + var rows []confirmRow + if m.preview.InstallOhMyZsh { + rows = append(rows, rowShell) + } + if m.preview.DotfilesURL != "" { + rows = append(rows, rowDotfiles) + } + if len(m.preview.MacOSPrefs) > 0 { + rows = append(rows, rowPrefs) + } + return rows +} + +// enterConfirm computes the plan preview and shows the confirm screen. +func (m Model) enterConfirm() (tea.Model, tea.Cmd) { + m.preview = installer.PlanFromSelection(m.opts, m.selected) + m.confShell, m.confDotfiles, m.confPrefs = true, true, true + m.confCur = 0 + m.screen = scrConfirm + return m, nil +} + +func (m Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + rows := m.confirmRows() + switch msg.String() { + case "esc": + m.screen = scrSelect + case "up", "k": + if m.confCur > 0 { + m.confCur-- + } + case "down", "j": + if m.confCur < len(rows)-1 { + m.confCur++ + } + case " ": + if len(rows) > 0 { + switch rows[clamp(m.confCur, 0, len(rows)-1)] { + case rowShell: + m.confShell = !m.confShell + case rowDotfiles: + m.confDotfiles = !m.confDotfiles + case rowPrefs: + m.confPrefs = !m.confPrefs + } + } + case "q": + m.quit = true + return m, tea.Quit + case "enter": + return m.startInstall() + } + return m, nil +} + +func (m Model) confirmBody(_, _ int) string { + const pad = " " + var b []string + b = append(b, "") + b = append(b, "") + b = append(b, pad+fg(cTextHi).Bold(true).Render("Ready to install")) + b = append(b, pad+fg(cDim3).Render("Everything below runs when you press ↵ — space toggles a step off.")) + b = append(b, "") + + // Packages summary (informational, not toggleable). + toInstall := m.toInstallCount() + skipped := m.selCount() - toInstall + pkgLine := fg(cAccentHi).Render(fmt.Sprintf("%d to install", toInstall)) + + fg(cDim).Render(fmt.Sprintf(" · ~%d min", m.estMin())) + if skipped > 0 { + pkgLine += fg(cDim3).Render(fmt.Sprintf(" · %d already present", skipped)) + } + b = append(b, pad+" "+fg(cDim2).Render(padTo("packages", 12))+pkgLine) + + // Git identity (informational). + gitVal := m.gitName + " <" + m.gitEmail + ">" + if strings.TrimSpace(m.gitName) == "" { + if m.preview.SkipGit { + gitVal = "not configured — skipped" + } else { + gitVal = m.preview.GitName + " <" + m.preview.GitEmail + ">" + } + } + b = append(b, pad+" "+fg(cDim2).Render(padTo("git", 12))+fg(cMuted).Render(gitVal)) + b = append(b, "") + + rows := m.confirmRows() + for i, r := range rows { + b = append(b, pad+m.renderConfirmRow(r, i == clamp(m.confCur, 0, len(rows)-1))) + } + if len(rows) > 0 { + b = append(b, "") + } + b = append(b, pad+fg(cDim3).Render("↑↓ move · space toggle · ↵ install · esc back")) + return strings.Join(b, "\n") +} + +func (m Model) renderConfirmRow(r confirmRow, cursor bool) string { + var on bool + var name, desc string + switch r { + case rowShell: + on, name = m.confShell, "oh-my-zsh" + desc = "zsh setup, theme & plugins" + case rowDotfiles: + on, name = m.confDotfiles, "dotfiles" + desc = m.preview.DotfilesURL + " → symlinked (backups kept)" + case rowPrefs: + on, name = m.confPrefs, "macOS prefs" + desc = fmt.Sprintf("%d preferences · restarts Dock & Finder", len(m.preview.MacOSPrefs)) + } + + box := fg(cDim3).Render("◯") + nameStyle := fg(cMuted) + if on { + box = fg(cAccent).Render("◉") + nameStyle = fg(cText) + } + prefix := " " + if cursor { + prefix = fg(cAccent).Render("› ") + nameStyle = fg(cWhite).Bold(true) + } + return prefix + box + " " + nameStyle.Render(padTo(name, 12)) + fg(cDim).Render(desc) +} diff --git a/internal/ui/tui/wizard/frames_test.go b/internal/ui/tui/wizard/frames_test.go index e15ec46..58db161 100644 --- a/internal/ui/tui/wizard/frames_test.go +++ b/internal/ui/tui/wizard/frames_test.go @@ -85,6 +85,19 @@ func TestDumpFrames(t *testing.T) { g.gitField = 1 t.Log("\n===== GIT (capture) =====\n" + g.View()) + // Confirm (review plan). + c := send(sel, key("2")) + c.gitName, c.gitEmail = "Jane Developer", "jane@ex.io" + c.preview = installer.InstallPlan{ + InstallOhMyZsh: true, + DotfilesURL: "https://github.com/openbootdotdev/dotfiles", + MacOSPrefs: make([]macos.Preference, 64), + } + c.confShell, c.confDotfiles, c.confPrefs = true, true, false + c.confCur = 2 + c.screen = scrConfirm + t.Log("\n===== CONFIRM (review) =====\n" + c.View()) + // Install: synthetic pipeline (no real Apply). inst := installFrame(m, W, H) t.Log("\n===== INSTALL (running) =====\n" + inst.View()) @@ -106,7 +119,7 @@ func installFrame(m Model, _, _ int) Model { } m.plan.InstallOhMyZsh = true m.plan.DotfilesURL = "https://github.com/x/dotfiles" - m.phases = buildPhases(m.plan, m.installed) + m.phases = buildPhases(m.plan) m.installTick = m.ticks m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepStart, Command: "brew install node"}}) diff --git a/internal/ui/tui/wizard/git.go b/internal/ui/tui/wizard/git.go index 1e16863..0cecf53 100644 --- a/internal/ui/tui/wizard/git.go +++ b/internal/ui/tui/wizard/git.go @@ -2,6 +2,7 @@ package wizard import ( "strings" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" @@ -39,7 +40,7 @@ func (m Model) updateGit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } if strings.TrimSpace(m.gitName) != "" && strings.TrimSpace(m.gitEmail) != "" { - return m.startInstall() + return m.enterConfirm() } // Focus whichever field is still empty. if strings.TrimSpace(m.gitName) == "" { @@ -66,11 +67,14 @@ func (m Model) updateGit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// trimLast removes the final rune (not byte) — a byte slice would leave +// invalid UTF-8 behind after backspacing multi-byte input like "张" or "é". func trimLast(s string) string { if s == "" { return s } - return s[:len(s)-1] + _, size := utf8.DecodeLastRuneInString(s) + return s[:len(s)-size] } func (m Model) gitBody(_, _ int) string { diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index ea5f539..04be433 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -73,14 +73,29 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { plan.GitEmail = m.gitEmail plan.SkipGit = false } + // Honor the confirm screen's toggles — a step switched off there must not + // run. + if !m.confShell { + plan.InstallOhMyZsh = false + plan.ShellTheme = "" + plan.ShellPlugins = nil + } + if !m.confDotfiles { + plan.DotfilesURL = "" + } + if !m.confPrefs { + plan.MacOSPrefs = nil + } // Force non-interactive Apply: guarantees no huh prompt (git/npm-retry/ // screen-recording reminder) fires mid-alt-screen. All decisions are // already resolved in the plan. plan.Silent = true m.plan = plan - m.phases = buildPhases(plan, m.installed) + m.phases = buildPhases(plan) m.logs = nil + m.skippedPkgs = 0 + m.aborting = false m.screen = scrInstall m.installing = true m.done = false @@ -112,30 +127,21 @@ func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea } // buildPhases derives the pipeline sidebar from the plan. Package-phase totals -// count only packages not already installed (matching what the engine will -// actually emit events for), so the sidebar counts and progress bar stay -// accurate on a non-fresh Mac. -func buildPhases(plan installer.InstallPlan, installed map[string]bool) []phaseState { - countNew := func(names []string) int { - n := 0 - for _, name := range names { - if !installed[name] { - n++ - } - } - return n - } +// count every planned package: the engine's streaming invariant is that each +// one produces exactly one terminal event (installed, failed, or +// already-installed skip), so totals and the event stream reconcile by +// construction — including alias-resolved and state-file skips. +func buildPhases(plan installer.InstallPlan) []phaseState { var ps []phaseState add := func(name string, total int, pkg, present bool) { if present { ps = append(ps, phaseState{name: name, total: total, pkg: pkg}) } } - nFormulae, nCasks, nNpm := countNew(plan.Formulae), countNew(plan.Casks), countNew(plan.Npm) add("Git identity", 1, false, !plan.PackagesOnly && !plan.SkipGit) - add(progress.PhaseHomebrew, nFormulae, true, nFormulae > 0) - add(progress.PhaseApplications, nCasks, true, nCasks > 0) - add(progress.PhaseNpm, nNpm, true, nNpm > 0) + add(progress.PhaseHomebrew, len(plan.Formulae), true, len(plan.Formulae) > 0) + add(progress.PhaseApplications, len(plan.Casks), true, len(plan.Casks) > 0) + add(progress.PhaseNpm, len(plan.Npm), true, len(plan.Npm) > 0) add("Shell", 1, false, !plan.PackagesOnly && plan.InstallOhMyZsh) add("Dotfiles", 1, false, !plan.PackagesOnly && plan.DotfilesURL != "") add("macOS prefs", 1, false, !plan.PackagesOnly && len(plan.MacOSPrefs) > 0) @@ -175,13 +181,26 @@ func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { m.installing = false m.done = true m.installErr = t.err - for i := range m.phases { - m.phases[i].active = false - m.phases[i].finished = true + if m.aborting && m.installErr == nil { + m.installErr = ErrAborted } if m.cancel != nil { m.cancel() } + // Only a clean run gets every phase check-marked; on error or abort, + // phases that never completed stay visibly unfinished. + for i := range m.phases { + m.phases[i].active = false + if m.installErr == nil { + m.phases[i].finished = true + } + } + if m.aborting { + // The user asked to leave; the engine has now stopped — quit and + // let the CLI report the abort on a normal terminal. + m.quit = true + return m, tea.Quit + } return m, nil } return m, nil @@ -203,7 +222,12 @@ func (m *Model) applyProgressEvent(ev progress.Event) { if ev.Detail != "" { text += " — " + ev.Detail } - m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}) + if ev.Detail == progress.SkipDetail { + m.skippedPkgs++ + m.appendLog(logLine{mark: "○", markColor: cDim3, text: text, color: cDim}) + } else { + m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}) + } case progress.StepFail: m.incPhase(ev.Phase) m.appendLog(logLine{mark: "✗", markColor: cDanger, text: ev.Name + " (" + ev.Detail + ")", color: cDanger}) @@ -239,7 +263,11 @@ func (m *Model) activatePhase(name string) { func (m *Model) incPhase(name string) { for i := range m.phases { if m.phases[i].name == name { - m.phases[i].done++ + // Clamp: a retry pass emits a second terminal event for the same + // package; don't let done overrun the total. + if m.phases[i].done < m.phases[i].total { + m.phases[i].done++ + } if m.phases[i].pkg && m.phases[i].done >= m.phases[i].total { m.phases[i].active = false m.phases[i].finished = true @@ -427,7 +455,7 @@ func (m Model) logView(w, h int) []string { func (m Model) installFooter(w int) []string { if m.done { - pkgN := m.pkgCount() + pkgN := m.pkgCount() - m.skippedPkgs head := fg(cAccent).Render("✓") + " " + fg(cTextHi).Bold(true).Render("This Mac is dev-ready.") + " " + fg(cDim3).Render(fmt.Sprintf("%d packages · %ds", pkgN, m.elapsed())) if m.installErr != nil { diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index 9f6a6fc..eced975 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -109,7 +109,7 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy } case "backspace": if len(m.query) > 0 { - m.query = m.query[:len(m.query)-1] + m.query = trimLast(m.query) m.rowCur, m.scroll = 0, 0 } case "up": @@ -182,13 +182,14 @@ func (m Model) tryInstall() (tea.Model, tea.Cmd) { if m.toInstallCount() == 0 { return m, nil // nothing to install — stay put } - // Capture a git identity first when none is configured (fresh Mac). + // Capture a git identity first when none is configured (fresh Mac), then + // review the full plan before anything runs. if need, name, email := m.needsGitCapture(); need { m.gitName, m.gitEmail, m.gitField = name, email, 0 m.screen = scrGit return m, nil } - return m.startInstall() + return m.enterConfirm() } // ── rendering ── diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index f49107e..d91827e 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -24,11 +24,19 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s case scrGit: return "GIT", cAccent, "↑↓/tab switch field · ↵ continue · esc back", "identity for your commits" + case scrConfirm: + return "REVIEW", cAccent, "↑↓ move · space toggle · ↵ install · esc back", + fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) + default: // scrInstall if m.done { return "DONE", cAccent, "r replay from boot · q quit", fmt.Sprintf("%d steps · %ds", m.totalSteps(), m.elapsed()) } + if m.aborting { + return "ABORT", cDanger, "aborting — waiting for the current step to stop · ctrl+c again to force quit", + fmt.Sprintf("%d/%d · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) + } return "INSTALL", cWarn, "installing — everything is logged to ~/.openboot/logs", fmt.Sprintf("%d/%d · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) } diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 4489c1b..3409c37 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -9,6 +9,7 @@ package wizard import ( "context" + "errors" "fmt" "os" "strings" @@ -27,9 +28,14 @@ const ( scrBoot screen = iota scrSelect scrGit + scrConfirm scrInstall ) +// ErrAborted is returned by Run when the user cancels a running install with +// ctrl+c. It distinguishes a deliberate abort from install failures. +var ErrAborted = errors.New("installation aborted") + // tickInterval drives the spinner and the derived elapsed clock. const tickInterval = 120 * time.Millisecond @@ -67,6 +73,13 @@ type Model struct { gitEmail string gitField int // 0 = name, 1 = email + // ── confirm (pre-install review) ── + preview installer.InstallPlan // what this run would do, for display + toggles + confShell bool + confDotfiles bool + confPrefs bool + confCur int + // ── install ── events chan tea.Msg plan installer.InstallPlan @@ -74,8 +87,10 @@ type Model struct { logs []logLine curStep string installing bool + aborting bool // ctrl+c received mid-install; waiting for the engine to stop done bool installErr error + skippedPkgs int // terminal events with SkipDetail (already installed) installTick int // ticks value when install started, for elapsed cancel context.CancelFunc } @@ -121,9 +136,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if msg.String() == "ctrl+c" { + // First ctrl+c during a running install requests an abort: cancel + // the context and keep the TUI up until the engine reports back + // (installDoneMsg), so the goroutine is joined and the abort is + // reported honestly. A second ctrl+c force-quits. + if m.screen == scrInstall && m.installing && !m.aborting { + m.aborting = true + if m.cancel != nil { + m.cancel() + } + return m, nil + } if m.cancel != nil { m.cancel() } + if m.installing { + m.installErr = ErrAborted + } m.quit = true return m, tea.Quit } @@ -134,6 +163,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSelect(msg) case scrGit: return m.updateGit(msg) + case scrConfirm: + return m.updateConfirm(msg) case scrInstall: return m.updateInstall(msg) } @@ -170,6 +201,8 @@ func (m Model) View() string { body = m.selectBody(m.width, bodyH) case scrGit: body = m.gitBody(m.width, bodyH) + case scrConfirm: + body = m.confirmBody(m.width, bodyH) case scrInstall: body = m.installBody(m.width, bodyH) } @@ -185,6 +218,8 @@ func (m Model) crumb() string { return "select packages" case scrGit: return "git identity" + case scrConfirm: + return "review plan" default: if m.done { return "done" @@ -273,10 +308,13 @@ func truncCell(s string, w int) string { return lipgloss.NewStyle().MaxWidth(w).Render(s) } -// Run launches the wizard. It returns whether an install was started (confirmed) -// and any error from the install run. Stray stdout from the install engine is -// redirected away from the alt-screen for the program's lifetime. -func Run(version string, opts *config.InstallOptions) (confirmed bool, err error) { +// Run launches the wizard. It returns the applied plan, whether an install was +// started (confirmed), and any error from the install run (ErrAborted when the +// user cancelled mid-install). Stray stdout from the install engine is +// redirected away from the alt-screen for the program's lifetime; the abort +// flow keeps the TUI alive until the engine goroutine reports done, so the +// redirect isn't restored under its feet. +func Run(version string, opts *config.InstallOptions) (plan installer.InstallPlan, confirmed bool, err error) { m := New(version, opts) realOut := os.Stdout @@ -291,8 +329,8 @@ func Run(version string, opts *config.InstallOptions) (confirmed bool, err error p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(realOut), tea.WithInput(os.Stdin)) final, runErr := p.Run() if runErr != nil { - return false, fmt.Errorf("run wizard: %w", runErr) + return installer.InstallPlan{}, false, fmt.Errorf("run wizard: %w", runErr) } fm := final.(Model) - return fm.confirmed, fm.installErr + return fm.plan, fm.confirmed, fm.installErr } diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 778e216..aaf5cb0 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -147,9 +147,13 @@ func TestGitCaptureWhenUnconfigured(t *testing.T) { for _, r := range "jane@ex.io" { m = send(m, key(string(r))) } - // Enter on the email field with both filled proceeds to install. + // Enter on the email field with both filled proceeds to the confirm + // screen; a second enter starts the install. next, _ := m.Update(key("enter")) m = next.(Model) + require.Equal(t, scrConfirm, m.screen, "git capture flows into the review screen") + next, _ = m.Update(key("enter")) + m = next.(Model) require.Equal(t, scrInstall, m.screen) assert.Equal(t, "Jane Dev", m.plan.GitName) @@ -165,7 +169,59 @@ func TestGitCaptureSkippedWhenConfigured(t *testing.T) { m.installed = map[string]bool{} next, _ := m.Update(key("enter")) m = next.(Model) - assert.Equal(t, scrInstall, m.screen, "configured git skips the capture screen") + assert.Equal(t, scrConfirm, m.screen, "configured git goes straight to review") +} + +// The confirm screen's toggles must gate the plan: a step switched off there +// must not reach the engine. +func TestConfirmtogglesGateThePlan(t *testing.T) { + defer stubGitConfig("Ada", "ada@ex.io")() + + m := finishProbes(sized(96, 30)) + m = send(m, key("2")) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrConfirm, m.screen) + require.True(t, m.preview.InstallOhMyZsh, "preview computed on entry") + + // Toggle every row off: shell, dotfiles, prefs. + rows := m.confirmRows() + for range rows { + m = send(m, key("space")) + m = send(m, key("down")) + } + next, _ := m.Update(key("enter")) + m = next.(Model) + + require.Equal(t, scrInstall, m.screen) + assert.False(t, m.plan.InstallOhMyZsh, "shell toggled off") + assert.Empty(t, m.plan.DotfilesURL, "dotfiles toggled off") + assert.Empty(t, m.plan.MacOSPrefs, "prefs toggled off") +} + +// ctrl+c during a running install must request an abort (stay in the TUI, +// cancel the context) and only quit once the engine reports done — with a +// non-nil ErrAborted so the CLI exits non-zero. +func TestCtrlCDuringInstallAbortsHonestly(t *testing.T) { + plan := installer.InstallPlan{Formulae: []string{"a"}, SkipGit: true} + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.installing = true + m.phases = buildPhases(plan) + m.cancel = func() {} + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = next.(Model) + assert.Nil(t, cmd, "first ctrl+c must not quit — it waits for the engine") + require.True(t, m.aborting) + + next, _ = m.Update(installDoneMsg{}) + m = next.(Model) + assert.ErrorIs(t, m.installErr, ErrAborted) + assert.True(t, m.quit) + for _, p := range m.phases { + assert.False(t, p.finished, "aborted phases must not show as finished") + } } func TestGitScreenEscReturnsToSelect(t *testing.T) { @@ -179,6 +235,15 @@ func TestGitScreenEscReturnsToSelect(t *testing.T) { assert.Equal(t, scrSelect, m.screen) } +// Backspace must remove one rune, not one byte — a byte slice corrupts +// multi-byte input (张三, José) into invalid UTF-8 that would reach git config. +func TestTrimLastIsRuneAware(t *testing.T) { + assert.Equal(t, "张", trimLast("张三")) + assert.Equal(t, "Jos", trimLast("José")) + assert.Equal(t, "", trimLast("a")) + assert.Equal(t, "", trimLast("")) +} + // stubGitConfig swaps the git-identity lookup for tests and returns a restore. func stubGitConfig(name, email string) func() { prev := gitConfigLookup @@ -195,7 +260,7 @@ func TestBuildPhases(t *testing.T) { DotfilesURL: "x", MacOSPrefs: make([]macos.Preference, 1), } - phases := buildPhases(plan, nil) + phases := buildPhases(plan) var names []string for _, p := range phases { names = append(names, p.name) @@ -206,21 +271,50 @@ func TestBuildPhases(t *testing.T) { }, names) // PackagesOnly drops every config phase. - po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}, nil) + po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}) require.Len(t, po, 1) assert.Equal(t, progress.PhaseHomebrew, po[0].name) } -func TestBuildPhasesExcludesInstalledFromCounts(t *testing.T) { +// The streaming invariant: every planned package produces exactly one terminal +// event, so totals count the full plan and already-installed skips arrive as +// StepOK events with SkipDetail. +func TestSkipEventsCompletePhaseAndCountSkipped(t *testing.T) { plan := installer.InstallPlan{Formulae: []string{"a", "b", "c"}, SkipGit: true} - phases := buildPhases(plan, map[string]bool{"a": true, "b": true}) - require.Len(t, phases, 1) - assert.Equal(t, progress.PhaseHomebrew, phases[0].name) - assert.Equal(t, 1, phases[0].total, "only the not-installed package counts") - - // All installed → no package phase at all. - none := buildPhases(plan, map[string]bool{"a": true, "b": true, "c": true}) - assert.Empty(t, none) + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.phases = buildPhases(plan) + require.Equal(t, 3, m.phases[0].total, "totals count every planned package") + + feed := func(ev progress.Event) { + next, _ := m.Update(evMsg{ev: ev}) + m = next.(Model) + } + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: progress.SkipDetail}) + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "b", Status: progress.StepOK, Detail: progress.SkipDetail}) + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "c", Status: progress.StepOK, Detail: "1.2s"}) + + assert.True(t, m.phases[0].finished, "skips + installs complete the phase") + assert.Equal(t, 2, m.skippedPkgs) + assert.Equal(t, 1, m.pkgCount()-m.skippedPkgs, "DONE footer counts actual installs") +} + +// A retry pass emits a second terminal event for the same package; done must +// clamp at total instead of overrunning. +func TestIncPhaseClampsOnRetryEvents(t *testing.T) { + plan := installer.InstallPlan{Formulae: []string{"a"}, SkipGit: true} + m := New("1", &config.InstallOptions{}) + m.screen = scrInstall + m.phases = buildPhases(plan) + + feed := func(ev progress.Event) { + next, _ := m.Update(evMsg{ev: ev}) + m = next.(Model) + } + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepFail, Detail: "timeout"}) + feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: "retry succeeded"}) + assert.Equal(t, 1, m.phases[0].done, "retry event must not overrun the total") + assert.Equal(t, 1, m.completedSteps()) } func TestProgressEventsDrivePhasesAndLog(t *testing.T) { @@ -228,7 +322,7 @@ func TestProgressEventsDrivePhasesAndLog(t *testing.T) { plan := installer.InstallPlan{Formulae: []string{"a", "b"}, Casks: []string{"c"}, SkipGit: true} m := New("1", &config.InstallOptions{}) m.screen = scrInstall - m.phases = buildPhases(plan, nil) + m.phases = buildPhases(plan) feed := func(ev progress.Event) { next, _ := m.Update(evMsg{ev: ev}) @@ -265,7 +359,7 @@ func TestReporterHeaderActivatesConfigPhase(t *testing.T) { plan := installer.InstallPlan{InstallOhMyZsh: true, SkipGit: true} m := New("1", &config.InstallOptions{}) m.screen = scrInstall - m.phases = buildPhases(plan, nil) + m.phases = buildPhases(plan) require.Len(t, m.phases, 1) next, _ := m.Update(reporterMsg{kind: rHeader, text: "Shell Configuration"}) @@ -278,7 +372,7 @@ func TestInstallDoneMarksAllFinished(t *testing.T) { m := New("1", &config.InstallOptions{}) m.screen = scrInstall m.installing = true - m.phases = buildPhases(plan, nil) + m.phases = buildPhases(plan) next, _ := m.Update(installDoneMsg{}) m = next.(Model) @@ -330,7 +424,7 @@ func TestInstallGoroutineStreamsToDone(t *testing.T) { plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) plan.Silent = true m.plan = plan - m.phases = buildPhases(plan, nil) + m.phases = buildPhases(plan) m.installing = true // Start the background install; the cmd returns nil and feeds m.events. diff --git a/test/e2e/install_wizard_e2e_test.go b/test/e2e/install_wizard_e2e_test.go index 6546365..2beb2a3 100644 --- a/test/e2e/install_wizard_e2e_test.go +++ b/test/e2e/install_wizard_e2e_test.go @@ -195,6 +195,10 @@ func TestE2E_InstallWizard_FullChoreography(t *testing.T) { require.True(t, s.waitFor("ci@example.com", 10*time.Second), "email field rendered; output:\n%s", s.out.String()) + s.send(t, "\r") // → review screen + + require.True(t, s.waitFor("Ready to install", 10*time.Second), + "confirm screen; output:\n%s", s.out.String()) // Stop here — the next enter would start a real install (L4's job). s.send(t, "\x03") @@ -202,5 +206,6 @@ func TestE2E_InstallWizard_FullChoreography(t *testing.T) { got := s.out.String() assert.Contains(t, got, "GIT", "git screen status badge rendered") + assert.Contains(t, got, "REVIEW", "confirm screen status badge rendered") assert.Contains(t, got, "\x1b[?1049l", "terminal restored") } diff --git a/test/e2e/install_wizard_vm_test.go b/test/e2e/install_wizard_vm_test.go index a3da79c..e920933 100644 --- a/test/e2e/install_wizard_vm_test.go +++ b/test/e2e/install_wizard_vm_test.go @@ -66,6 +66,7 @@ func TestVM_WizardTUI_RealInstall(t *testing.T) { {Expect: "type to filter", Send: "/" + formula + "\r"}, {Expect: "1 pkgs", Send: "\r"}, {Expect: "Set your git identity", Send: "CI Bot\tci@openboot.test\r"}, + {Expect: "Ready to install", Send: "\r"}, {Expect: "snapshot publish", Send: "q"}, }, 900) t.Logf("wizard session:\n%s", output)