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/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/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 7de4d42..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:327 +internal/brew/brew_install.go:356 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..29a3aa5 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" ) @@ -109,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 { @@ -142,14 +147,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 +173,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 +198,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, @@ -221,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) @@ -228,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) + } } } @@ -278,7 +307,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 +317,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, @@ -331,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 new file mode 100644 index 0000000..369048d --- /dev/null +++ b/internal/brew/streaming.go @@ -0,0 +1,69 @@ +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 } + +// 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) { + 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/cli/install.go b/internal/cli/install.go index 96a7fe9..06f4917 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,13 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { if err := applyInstallSource(src); err != nil { return fmt.Errorf("apply install source: %w", err) } + + // --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() + } } pickRaw, _ := cmd.Flags().GetString("pick") @@ -151,6 +159,34 @@ 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, --dry-run, and --update keep their existing flows. +func shouldLaunchWizard(src *installSource) bool { + 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); 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() + 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 +} + // ── Source resolution ───────────────────────────────────────────────────────── type sourceKind int 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/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) +} 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 f9bd246..0b489fc 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" ) @@ -108,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 { @@ -178,17 +183,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) } @@ -201,34 +218,48 @@ 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 } - 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..52a40c1 --- /dev/null +++ b/internal/npm/streaming.go @@ -0,0 +1,63 @@ +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 } + +// 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() { + 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 + } + // Print then Increment, matching the original console ordering exactly. + if ok { + bar.PrintLine(" ✔ %s", name) + } else { + bar.PrintLine(" ✗ %s (%s)", name, errMsg) + } + bar.Increment() +} 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..9b93a1f --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,56 @@ +// 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" +) + +// 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) + +// 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) +} 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/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 new file mode 100644 index 0000000..58db161 --- /dev/null +++ b/internal/ui/tui/wizard/frames_test.go @@ -0,0 +1,131 @@ +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()) + + // 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()) + + // 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()) + + 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/git.go b/internal/ui/tui/wizard/git.go new file mode 100644 index 0000000..0cecf53 --- /dev/null +++ b/internal/ui/tui/wizard/git.go @@ -0,0 +1,109 @@ +package wizard + +import ( + "strings" + "unicode/utf8" + + 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.enterConfirm() + } + // 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: + // 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 { + m.gitEmail += s + } + } + } + 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 + } + _, size := utf8.DecodeLastRuneInString(s) + return s[:len(s)-size] +} + +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 new file mode 100644 index 0000000..04be433 --- /dev/null +++ b/internal/ui/tui/wizard/install.go @@ -0,0 +1,490 @@ +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) + // 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 + } + // 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.logs = nil + m.skippedPkgs = 0 + m.aborting = false + m.screen = scrInstall + m.installing = true + m.done = false + m.confirmed = true + m.installTick = m.ticks + + 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)) +} + +// 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 + } +} + +// buildPhases derives the pipeline sidebar from the plan. Package-phase totals +// 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}) + } + } + 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 +} + +// 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) { + 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 + 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 +} + +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 + } + 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}) + } +} + +// 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 { + // 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 + } + 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 + 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 +} + +// ── 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 := 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 { + 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..eced975 --- /dev/null +++ b/internal/ui/tui/wizard/select.go @@ -0,0 +1,363 @@ +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 = trimLast(m.query) + m.rowCur, m.scroll = 0, 0 + } + case "up": + if m.rowCur > 0 { + m.rowCur-- + } + case "down": + if m.rowCur < last { + m.rowCur++ + } + default: + // 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 + } + } + 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 + } + // 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.enterConfirm() +} + +// ── 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..d91827e --- /dev/null +++ b/internal/ui/tui/wizard/status.go @@ -0,0 +1,43 @@ +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()) + + 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/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..3409c37 --- /dev/null +++ b/internal/ui/tui/wizard/wizard.go @@ -0,0 +1,336 @@ +// 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" + "errors" + "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 + 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 + +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 + + // ── git identity (captured only when none is configured) ── + gitName string + 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 + phases []phaseState + 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 +} + +// 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" { + // 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 + } + switch m.screen { + case scrBoot: + return m.updateBoot(msg) + case scrSelect: + return m.updateSelect(msg) + case scrGit: + return m.updateGit(msg) + case scrConfirm: + return m.updateConfirm(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 scrGit: + body = m.gitBody(m.width, bodyH) + case scrConfirm: + body = m.confirmBody(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" + case scrGit: + return "git identity" + case scrConfirm: + return "review plan" + 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 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 + 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 installer.InstallPlan{}, false, fmt.Errorf("run wizard: %w", runErr) + } + fm := final.(Model) + 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 new file mode 100644 index 0000000..aaf5cb0 --- /dev/null +++ b/internal/ui/tui/wizard/wizard_test.go @@ -0,0 +1,459 @@ +package wizard + +import ( + "context" + "strings" + "testing" + "time" + + 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) +} + +// 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")) + 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 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 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) + 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, 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) { + 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) +} + +// 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 + gitConfigLookup = func() (string, string) { return name, email } + return func() { gitConfigLookup = prev } +} + +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) +} + +// 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} + 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) { + // 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")) + 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 { + 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") +} + +// 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 { + out[i] = l.text + } + return out +} diff --git a/test/e2e/install_wizard_e2e_test.go b/test/e2e/install_wizard_e2e_test.go new file mode 100644 index 0000000..2beb2a3 --- /dev/null +++ b/test/e2e/install_wizard_e2e_test.go @@ -0,0 +1,211 @@ +//go:build e2e && !vm + +// 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 +// 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 ( + "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() +} + +// wizardSession is a live wizard process on a pty. +type wizardSession struct { + stdin io.WriteCloser + out *syncBuffer + done chan error + cmd *exec.Cmd +} + +// 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). + 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) + out := &syncBuffer{} + cmd.Stdout = out + cmd.Stderr = out + + require.NoError(t, cmd.Start()) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + 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 +} + +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 := <-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()) + + 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") + // 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") +} + +// 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()) + 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") + s.expectExit(t, 15*time.Second) + + 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_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..e920933 --- /dev/null +++ b/test/e2e/install_wizard_vm_test.go @@ -0,0 +1,93 @@ +//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: "Ready to install", Send: "\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")