diff --git a/CLAUDE.md b/CLAUDE.md index 51ff8d6..bdf9a64 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` 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. +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); `-p ` enters the same wizard with the loadout preselected, and slug/`-u`/`--from`/alias installs enter it in config mode (`RunForConfig`: the config's own packages on the select screen, preselected). Sync-source installs keep their linear diff pre-flight but stream the apply through the wizard's live install screen (`RunPipeline`). `--silent`, `--dry-run`, `--update`, and non-TTY runs 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. diff --git a/internal/cli/install.go b/internal/cli/install.go index eb8f4a2..f4f960c 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -136,6 +136,12 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / return perr } installCfg.RemoteConfig = rc + } else if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() { + // The full-screen config wizard owns the whole interactive flow + // (select the config's packages → review → live install) — + // replacing the linear 3-way prompt + customizer that used to + // precede the pipeline screen for slug / -u / --from / alias. + return runConfigWizard(installCfg.RemoteConfig) } else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) { rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig) if err != nil { @@ -168,15 +174,33 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / 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. +// shouldLaunchWizard reports whether this run gets the full-screen wizard +// (boot probe → select → live install) on a TTY: a bare `openboot install`, +// or a valid preset (-p / OPENBOOT_PRESET / positional), which enters the +// wizard with that loadout preselected on the select screen. Remote sources +// (--from, -u, slug, sync), --silent, --dry-run, and --update keep their +// existing flows. func shouldLaunchWizard(src *installSource) bool { - return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && + return wizardSource(src) && !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() } +// wizardSource is the source-kind half of the wizard-routing decision, split +// from the TTY/mode checks so it's testable without a terminal. +func wizardSource(src *installSource) bool { + switch src.kind { + case sourceNone: + return true + case sourcePreset: + // An unknown preset must keep the linear path, which rejects it with a + // clear error instead of silently opening an empty wizard. + _, ok := config.GetPreset(installCfg.Preset) + return ok + default: + return false + } +} + // runPipelineInstall resolves the plan (with linear pre-flight prep) and applies // it through the wizard's live pipeline screen, so a slug/preset/RemoteConfig // install streams like the wizard. ApplyContext is the same engine RunContext @@ -256,6 +280,35 @@ func runInstallWizard() error { return nil } +// runConfigWizard launches the full-screen wizard in config mode for a fetched +// remote config. Follow-ups the alt-screen can't host — the post-install +// script (needs a preview + confirm) and the screen-recording reminder — run +// here afterwards, on a normal terminal, mirroring runPipelineInstall's order. +func runConfigWizard(rc *config.RemoteConfig) error { + opts := installCfg.ToInstallOptions() + plan, confirmed, err := wizard.RunForConfig(installCfg.Version, opts, rc) + if errors.Is(err, wizard.ErrAborted) || errors.Is(err, context.Canceled) { + return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") + } + if !confirmed { + if err != nil { + return fmt.Errorf("install wizard: %w", err) + } + ui.Info("Cancelled.") + return nil + } + if err == nil && len(plan.PostInstall) > 0 { + if piErr := installer.RunPostInstallAfterTUI(plan); piErr != nil { + ui.Error(fmt.Sprintf("Post-install script failed: %v", piErr)) + } + } + installer.ShowScreenRecordingReminderAfterTUI(plan) + if err == nil { + saveSyncSourceIfRemote(installCfg) + } + return err +} + // ── Source resolution ───────────────────────────────────────────────────────── type sourceKind int diff --git a/internal/cli/wizard_routing_test.go b/internal/cli/wizard_routing_test.go new file mode 100644 index 0000000..b0df77d --- /dev/null +++ b/internal/cli/wizard_routing_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// wizardSource is the source-kind half of shouldLaunchWizard, split out so the +// routing is testable without a TTY: bare installs and valid presets get the +// full wizard; unknown presets and remote sources keep the linear path. +func TestWizardSource(t *testing.T) { + oldPreset := installCfg.Preset + defer func() { installCfg.Preset = oldPreset }() + + assert.True(t, wizardSource(&installSource{kind: sourceNone}), "bare install") + + installCfg.Preset = "developer" + assert.True(t, wizardSource(&installSource{kind: sourcePreset}), "valid preset") + + installCfg.Preset = "not-a-preset" + assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset keeps the linear error path") + + assert.False(t, wizardSource(&installSource{kind: sourceCloud}), "cloud config") + assert.False(t, wizardSource(&installSource{kind: sourceSyncSource}), "sync source") + assert.False(t, wizardSource(&installSource{kind: sourceFile}), "local file") +} diff --git a/internal/installer/plan.go b/internal/installer/plan.go index d291f3f..6aec411 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -309,7 +309,9 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) } // PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit -// package selection gathered by the install TUI. It applies system-config +// package selection gathered by the install TUI. online carries packages the +// wizard picked from openboot.dev search — they aren't in the local catalog, +// so categorization needs their type info. 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. // @@ -317,8 +319,8 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) // 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} +func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool, online []config.Package) InstallPlan { + st := &config.InstallState{SelectedPkgs: selected, OnlinePkgs: online} plan := InstallPlan{ Version: opts.Version, @@ -327,6 +329,7 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) In PackagesOnly: opts.PackagesOnly, AllowPostInstall: opts.AllowPostInstall, SelectedPkgs: selected, + OnlinePkgs: online, } cats := categorizeSelectedPackages(opts, st) @@ -366,6 +369,57 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) In return plan } +// PlanForRemoteSelection builds a ready-to-Apply InstallPlan from a remote +// config filtered to the wizard's package selection, plus any openboot.dev +// search picks made on the select screen. It is the config-mode counterpart +// of PlanFromSelection: everything non-package (git, dotfiles, shell, macOS +// prefs, dock, login items, post-install) comes from the remote config via +// planFromRemoteConfig, exactly like the declarative slug path — no prompts. +func PlanForRemoteSelection(opts *config.InstallOptions, rc *config.RemoteConfig, selected map[string]bool, online []config.Package) InstallPlan { + f := *rc + f.Packages = filterEntriesBySelection(rc.Packages, selected) + f.Casks = filterEntriesBySelection(rc.Casks, selected) + f.Npm = filterEntriesBySelection(rc.Npm, selected) + + plan := InstallPlan{ + Version: opts.Version, + DryRun: opts.DryRun, + Silent: opts.Silent, + PackagesOnly: opts.PackagesOnly, + AllowPostInstall: opts.AllowPostInstall, + RemoteConfig: &f, + } + st := &config.InstallState{RemoteConfig: &f} + planFromRemoteConfig(opts, st, &plan) + + for _, p := range online { + if !selected[p.Name] || plan.SelectedPkgs[p.Name] { + continue + } + switch { + case p.IsNpm: + plan.Npm = append(plan.Npm, p.Name) + case p.IsCask: + plan.Casks = append(plan.Casks, p.Name) + default: + plan.Formulae = append(plan.Formulae, p.Name) + } + plan.SelectedPkgs[p.Name] = true + plan.OnlinePkgs = append(plan.OnlinePkgs, p) + } + return plan +} + +func filterEntriesBySelection(in config.PackageEntryList, selected map[string]bool) config.PackageEntryList { + out := make(config.PackageEntryList, 0, len(in)) + for _, e := range in { + if selected[e.Name] { + out = append(out, e) + } + } + return out +} + // 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 index cd1ac6f..892e94d 100644 --- a/internal/installer/plan_selection_test.go +++ b/internal/installer/plan_selection_test.go @@ -14,7 +14,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) { sel := config.GetPackagesForPreset("developer") require.NotEmpty(t, sel) - plan := PlanFromSelection(opts, sel) + plan := PlanFromSelection(opts, sel, nil) assert.Equal(t, "1.0.0", plan.Version) assert.Positive(t, len(plan.Formulae)+len(plan.Casks)+len(plan.Npm), "packages categorized") @@ -35,7 +35,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) { func TestPlanFromSelectionPackagesOnly(t *testing.T) { opts := &config.InstallOptions{PackagesOnly: true} - plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) assert.True(t, plan.PackagesOnly) assert.False(t, plan.InstallOhMyZsh) @@ -46,9 +46,49 @@ func TestPlanFromSelectionPackagesOnly(t *testing.T) { func TestPlanFromSelectionSkipFlags(t *testing.T) { opts := &config.InstallOptions{Shell: "skip", Dotfiles: "skip", Macos: "skip"} - plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) assert.False(t, plan.InstallOhMyZsh) assert.Empty(t, plan.DotfilesURL) assert.Empty(t, plan.MacOSPrefs) } + +// Online picks come from openboot.dev search — not in the local catalog, so +// their type info must flow through OnlinePkgs into categorization. +func TestPlanFromSelectionIncludesOnlinePicks(t *testing.T) { + opts := &config.InstallOptions{PackagesOnly: true} + online := []config.Package{{Name: "web-only-tool", IsNpm: true}} + plan := PlanFromSelection(opts, map[string]bool{"web-only-tool": true}, online) + assert.Contains(t, plan.Npm, "web-only-tool") + assert.Equal(t, online, plan.OnlinePkgs) +} + +// Config-mode plans: the remote config filtered by the wizard's selection, +// with openboot.dev picks appended and all declarative fields carried. +func TestPlanForRemoteSelection(t *testing.T) { + rc := &config.RemoteConfig{ + Packages: config.PackageEntryList{{Name: "cowsay"}, {Name: "fortune"}}, + Casks: config.PackageEntryList{{Name: "warp"}}, + Npm: config.PackageEntryList{{Name: "left-pad"}}, + Taps: []string{"acme/tap"}, + DotfilesRepo: "https://github.com/alice/dotfiles", + Shell: &config.RemoteShellConfig{OhMyZsh: true, Theme: "agnoster", Plugins: []string{"git"}}, + PostInstall: []string{"echo hi"}, + } + sel := map[string]bool{"cowsay": true, "warp": true, "web-x": true} // fortune & left-pad deselected + online := []config.Package{{Name: "web-x", IsNpm: true}} + + plan := PlanForRemoteSelection(&config.InstallOptions{}, rc, sel, online) + + assert.Equal(t, []string{"cowsay"}, plan.Formulae) + assert.Equal(t, []string{"warp"}, plan.Casks) + assert.Equal(t, []string{"web-x"}, plan.Npm, "online pick lands with its npm type") + assert.Equal(t, []string{"acme/tap"}, plan.Taps, "taps ride along untouched") + assert.Equal(t, "https://github.com/alice/dotfiles", plan.DotfilesURL) + assert.True(t, plan.InstallOhMyZsh) + assert.Equal(t, "agnoster", plan.ShellTheme) + assert.Equal(t, []string{"echo hi"}, plan.PostInstall) + assert.True(t, plan.SelectedPkgs["web-x"]) + assert.False(t, plan.SelectedPkgs["fortune"]) + assert.Equal(t, online, plan.OnlinePkgs) +} diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 0b489fc..3fd4ad0 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -250,8 +250,9 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [ for _, pkg := range remaining { npmStepStart(bar, pkg) + start := time.Now() errMsg := installNpmPackageWithRetryContext(ctx, pkg) - npmStepDone(bar, pkg, errMsg == "", errMsg) + npmStepDone(bar, pkg, errMsg == "", errMsg, ui.FormatDuration(time.Since(start))) if errMsg != "" { failed = append(failed, pkg) } diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go index 52a40c1..3ac9fa0 100644 --- a/internal/npm/streaming.go +++ b/internal/npm/streaming.go @@ -43,11 +43,14 @@ func npmStepStart(bar *ui.StickyProgress, name string) { } // 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) { +// the exact console output when not streaming. duration is the measured +// install time (e.g. "2.1s") carried as the success Detail so streaming +// renderers show npm rows with the same timing brew rows get; empty means +// no per-package timing is available. +func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg, duration string) { if streaming() { if ok { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK}) + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK, Detail: duration}) } else { progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepFail, Detail: errMsg}) } diff --git a/internal/npm/streaming_test.go b/internal/npm/streaming_test.go index fa73ec7..0d2e803 100644 --- a/internal/npm/streaming_test.go +++ b/internal/npm/streaming_test.go @@ -26,12 +26,12 @@ func TestNpmStepHelpersEmitWhenStreaming(t *testing.T) { assert.NotPanics(t, func() { npmStepStart(nil, "typescript") - npmStepDone(nil, "typescript", true, "") - npmStepDone(nil, "eslint", false, "E404") + npmStepDone(nil, "typescript", true, "", "2.1s") + npmStepDone(nil, "eslint", false, "E404", "0.3s") }) 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: "typescript", Status: progress.StepOK, Detail: "2.1s"}, got[1]) assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "eslint", Status: progress.StepFail, Detail: "E404"}, got[2]) } diff --git a/internal/ui/tui/wizard/boot.go b/internal/ui/tui/wizard/boot.go index c84aac8..cedadd6 100644 --- a/internal/ui/tui/wizard/boot.go +++ b/internal/ui/tui/wizard/boot.go @@ -101,6 +101,20 @@ func (m Model) onProbeDone(msg probeDoneMsg) (tea.Model, tea.Cmd) { if m.probeIdx < len(m.probes) { return m, m.runProbe(m.probeIdx) } + // Config mode: the remote config already answers the loadout question — + // go straight to reviewing its (preselected) packages. + if m.rc != nil { + return m.enterSelect(m.selected) + } + // A preset given on the CLI (-p / OPENBOOT_PRESET) already answers the + // loadout question — skip straight to the select screen with it applied, + // so the flag means "start from this loadout, review, install" instead of + // bypassing the wizard entirely. + if m.opts.Preset != "" { + if _, ok := config.GetPreset(m.opts.Preset); ok { + return m.enterSelect(config.GetPackagesForPreset(m.opts.Preset)) + } + } return m, nil // probing complete; wait for a loadout choice } diff --git a/internal/ui/tui/wizard/config_mode_test.go b/internal/ui/tui/wizard/config_mode_test.go new file mode 100644 index 0000000..9b6294d --- /dev/null +++ b/internal/ui/tui/wizard/config_mode_test.go @@ -0,0 +1,105 @@ +package wizard + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/config" +) + +// Config mode: install / -u / --from / alias runs the full wizard over +// the remote config's own packages instead of the catalog. + +func testRemoteConfig() *config.RemoteConfig { + return &config.RemoteConfig{ + Username: "alice", + Slug: "dev", + Packages: config.PackageEntryList{{Name: "cowsay", Desc: "talking cow"}, {Name: "fortune"}}, + Casks: config.PackageEntryList{{Name: "warp"}}, + Npm: config.PackageEntryList{{Name: "left-pad"}}, + DotfilesRepo: "https://github.com/alice/dotfiles", + Shell: &config.RemoteShellConfig{OhMyZsh: true, Theme: "agnoster"}, + PostInstall: []string{"echo hi"}, + } +} + +func sizedConfig(rc *config.RemoteConfig) Model { + m := NewForConfig("1.4.0", &config.InstallOptions{Version: "1.4.0"}, rc) + return send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) +} + +func TestConfigModeShowsConfigPackagesPreselected(t *testing.T) { + m := sizedConfig(testRemoteConfig()) + require.Len(t, m.cats, 3, "cli tools / apps / npm categories from the config") + assert.Equal(t, 4, m.selCount(), "everything preselected") + assert.Contains(t, m.View(), "openboot install alice/dev", "status bar names the source") + + m = finishProbes(m) + assert.Equal(t, scrSelect, m.screen, "config mode skips the loadout question") + pool := m.pool() + require.NotEmpty(t, pool) + assert.Equal(t, "cowsay", pool[0].Name) +} + +func TestConfigModeSkipsGitScreenAndPlansFromConfig(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sizedConfig(testRemoteConfig())) + m.installed = map[string]bool{} + + // Deselect "fortune" on the select screen, then continue. + m.rowCur = 1 + m = send(m, key("space")) + require.False(t, m.selected["fortune"]) + m = send(m, key("enter")) + + require.Equal(t, scrConfirm, m.screen, "declarative installs never interpose the git prompt") + assert.NotContains(t, m.preview.Formulae, "fortune", "deselected package leaves the plan") + assert.Contains(t, m.preview.Formulae, "cowsay") + assert.Contains(t, m.preview.Casks, "warp") + assert.Contains(t, m.preview.Npm, "left-pad") + assert.Equal(t, "https://github.com/alice/dotfiles", m.preview.DotfilesURL) + assert.True(t, m.preview.InstallOhMyZsh) + assert.Equal(t, "agnoster", m.preview.ShellTheme) + assert.Equal(t, []string{"echo hi"}, m.preview.PostInstall) +} + +func TestConfigModeConfirmShowsPostInstallAndShiftsHitTest(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sizedConfig(testRemoteConfig())) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrConfirm, m.screen) + + assert.Contains(t, m.View(), "runs after the wizard", "post-install surfaced on the review screen") + require.Equal(t, 9, m.confirmHeaderRows(), "post-install line shifts the toggle rows down") + kind, idx := m.confirmHitTest(10) // body row 9 = first toggle row + assert.Equal(t, "row", kind) + assert.Equal(t, 0, idx) + _, miss := m.confirmHitTest(9) + assert.Equal(t, -1, miss, "the info line itself is not clickable") +} + +func TestConfigModeStartInstallDefersPostInstall(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sizedConfig(testRemoteConfig())) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrConfirm, m.screen) + m = send(m, key("enter")) // startInstall; the spawned engine cmd is dropped by send() + + require.Equal(t, scrInstall, m.screen) + require.True(t, m.confirmed) + assert.Equal(t, []string{"echo hi"}, m.plan.PostInstall, + "returned plan keeps post-install for the CLI to run after teardown") + assert.True(t, m.plan.Silent) + var names []string + for _, p := range m.phases { + names = append(names, p.name) + } + assert.NotContains(t, strings.Join(names, " "), "post-install", + "no post-install phase streams inside the alt-screen") +} diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go index a77a317..3425599 100644 --- a/internal/ui/tui/wizard/confirm.go +++ b/internal/ui/tui/wizard/confirm.go @@ -5,8 +5,6 @@ import ( "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 @@ -40,7 +38,7 @@ func (m Model) confirmRows() []confirmRow { // 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.preview = m.buildPlan() m.confShell, m.confDotfiles, m.confPrefs = true, true, true m.confCur, m.hoverRow = 0, -1 m.screen = scrConfirm @@ -97,18 +95,29 @@ func (m Model) confirmBody(_, _ int) string { if skipped > 0 { pkgLine += fg(cDim3).Render(fmt.Sprintf(" · %d already present", skipped)) } - b = append(b, pad+" "+fg(cDim2).Render(padTo("packages", 12))+pkgLine) + b = append(b, pad+" "+fg(cDim2).Render(padTo("packages", 13))+pkgLine) // Git identity (informational). gitVal := m.gitName + " <" + m.gitEmail + ">" if strings.TrimSpace(m.gitName) == "" { - if m.preview.SkipGit { + switch { + case m.preview.SkipGit: gitVal = "not configured — skipped" - } else { + case strings.TrimSpace(m.preview.GitName) == "": + // Config-mode plans carry no identity: the git step keeps an + // existing config and no-ops when there is none. + gitVal = "existing config kept — skipped when absent" + default: gitVal = m.preview.GitName + " <" + m.preview.GitEmail + ">" } } - b = append(b, pad+" "+fg(cDim2).Render(padTo("git", 12))+fg(cMuted).Render(gitVal)) + b = append(b, pad+" "+fg(cDim2).Render(padTo("git", 13))+fg(cMuted).Render(gitVal)) + // Post-install (informational): the script can't run inside the + // alt-screen, so it executes after the wizard, with its own confirm. + if n := len(m.preview.PostInstall); n > 0 { + b = append(b, pad+" "+fg(cDim2).Render(padTo("post-install", 13))+ + fg(cMuted).Render(fmt.Sprintf("%d command(s) · runs after the wizard, asks first", n))) + } b = append(b, "") rows := m.confirmRows() @@ -181,15 +190,24 @@ func (m Model) updateConfirmMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +// confirmHeaderRows is the number of body rows above the first toggleable row +// (2 blanks + title + subtitle + blank + packages + git + blank = 8, plus one +// for the post-install line when the plan carries a script). +func (m Model) confirmHeaderRows() int { + n := 8 + if len(m.preview.PostInstall) > 0 { + n++ + } + return n +} + // confirmHitTest maps a screen Y coordinate to a confirm-row index. -// Toggleable rows start at body row 8 (title + subtitle + blank + packages -// + git + blank = 7 headers before the first toggle row). func (m Model) confirmHitTest(y int) (string, int) { if !m.inBody(y) { return "", -1 } bodyRow := y - 1 - idx := bodyRow - 8 + idx := bodyRow - m.confirmHeaderRows() if idx >= 0 && idx < len(m.confirmRows()) { return "row", idx } diff --git a/internal/ui/tui/wizard/git.go b/internal/ui/tui/wizard/git.go index 884f7cc..b8dcb35 100644 --- a/internal/ui/tui/wizard/git.go +++ b/internal/ui/tui/wizard/git.go @@ -20,6 +20,12 @@ func (m Model) needsGitCapture() (need bool, name, email string) { if m.opts.PackagesOnly { return false, "", "" } + // Config-mode installs are declarative — the git step already no-ops + // safely when no identity exists (matching the slug pipeline path), so + // don't interpose a prompt the config never asked for. + if m.rc != nil { + return false, "", "" + } name, email = gitConfigLookup() if name != "" && email != "" { return false, name, email diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index 5b6bb42..23091de 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -64,8 +64,17 @@ func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text // ── starting the install ── +// buildPlan resolves the current selection into an install plan — from the +// remote config in config mode, from the catalog selection otherwise. +func (m Model) buildPlan() installer.InstallPlan { + if m.rc != nil { + return installer.PlanForRemoteSelection(m.opts, m.rc, m.selected, m.selectedOnlinePkgs()) + } + return installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) +} + func (m Model) startInstall() (tea.Model, tea.Cmd) { - plan := installer.PlanFromSelection(m.opts, m.selected) + plan := m.buildPlan() // 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. @@ -93,7 +102,12 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { plan.Silent = true m.plan = plan - m.phases = buildPhases(plan) + // The alt-screen can't host the post-install script's confirm prompt; the + // CLI runs it after teardown from the returned plan (m.plan keeps it) — + // strip it from the streamed apply so ApplyContext doesn't execute it here. + streamed := plan + streamed.PostInstall = nil + m.phases = buildPhases(streamed) m.logs = nil m.skippedPkgs = 0 m.aborting = false @@ -106,7 +120,7 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { 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)) + return m, tea.Batch(m.spawnInstall(ctx, streamed), waitForEvent(m.events)) } // spawnInstall runs the install engine on a background goroutine, streaming @@ -265,11 +279,34 @@ func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { m.quit = true return m, tea.Quit } + m.appendSummary() return m, nil } return m, nil } +// appendSummary writes the run's outcome into the log tail — the counts, and +// crucially the names of any failed packages, which would otherwise have +// scrolled out of the visible log by the time the user reads the DONE screen. +func (m *Model) appendSummary() { + installed := m.pkgCount() - m.skippedPkgs - len(m.failedPkgs) + if installed < 0 { + installed = 0 + } + text := fmt.Sprintf("%d installed · %d already present · %s", + installed, m.skippedPkgs, fmtElapsed(m.elapsed())) + m.appendLog(logLine{}) // spacer + if len(m.failedPkgs) == 0 && m.installErr == nil { + m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cTextHi}) + return + } + m.appendLog(logLine{mark: "!", markColor: cWarn, text: text, color: cTextHi}) + if len(m.failedPkgs) > 0 { + m.appendLog(logLine{mark: "✗", markColor: cDanger, + text: fmt.Sprintf("%d failed: %s", len(m.failedPkgs), strings.Join(m.failedPkgs, ", ")), color: cDanger}) + } +} + func (m *Model) applyProgressEvent(ev progress.Event) { m.activatePhase(ev.Phase) switch ev.Status { @@ -305,6 +342,9 @@ func (m *Model) applyProgressEvent(ev progress.Event) { case progress.StepFail: m.markTerminal(ev) m.incPhase(ev.Phase) + if ev.Name != "" { + m.failedPkgs = append(m.failedPkgs, ev.Name) + } m.appendLog(logLine{mark: "✗", markColor: cDanger, text: ev.Name + " (" + ev.Detail + ")", color: cDanger}) } } @@ -441,6 +481,15 @@ func (m Model) elapsed() int { return (m.ticks - m.installTick) * int(tickInterval.Milliseconds()) / 1000 } +// fmtElapsed renders whole seconds as "42s" or "3m24s" — a raw "7204s" after a +// long cask install reads as noise. +func fmtElapsed(secs int) string { + if secs < 60 { + return fmt.Sprintf("%ds", secs) + } + return fmt.Sprintf("%dm%ds", secs/60, secs%60) +} + // ── key handling ── func (m Model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -561,7 +610,7 @@ 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())) + " " + fg(cDim3).Render(fmt.Sprintf("%d packages · %s", pkgN, fmtElapsed(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") diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index 37c4b2d..d4e6004 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -3,12 +3,60 @@ package wizard import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/search" ) +// searchOnline is a seam so tests can stub the openboot.dev search client. +var searchOnline = search.SearchOnline + +const ( + // onlineCatName is the synthetic sidebar category that holds packages + // picked from openboot.dev search results (they aren't in the local + // catalog, so without a home they'd vanish when the filter clears). + onlineCatName = "online" + searchDebounce = 450 * time.Millisecond + searchMinRunes = 2 // don't hit the network for 1-char queries + onlineMaxHits = 20 +) + +// searchTickMsg fires after the debounce delay; stale generations are dropped. +type searchTickMsg struct { + seq int + query string +} + +// searchDoneMsg carries the online results for generation seq. +type searchDoneMsg struct { + seq int + results []config.Package +} + +// categoriesFromConfig maps a remote config's package lists onto sidebar +// categories, so the select screen browses a config the same way it browses +// the catalog. Empty lists produce no category. +func categoriesFromConfig(rc *config.RemoteConfig) []config.Category { + var cats []config.Category + add := func(name string, entries config.PackageEntryList, cask, npm bool) { + if len(entries) == 0 { + return + } + pkgs := make([]config.Package, 0, len(entries)) + for _, e := range entries { + pkgs = append(pkgs, config.Package{Name: e.Name, Description: e.Desc, IsCask: cask, IsNpm: npm}) + } + cats = append(cats, config.Category{Name: name, Packages: pkgs}) + } + add("cli tools", rc.Packages, false, false) + add("apps", rc.Casks, true, false) + add("npm", rc.Npm, false, true) + return cats +} + // ── selection helpers ── func (m Model) isInstalled(name string) bool { return m.installed[name] } @@ -43,7 +91,8 @@ 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. +// when a query is present — a substring match across the whole catalog plus +// any online hits for the query (already deduped against the catalog). func (m Model) pool() []config.Package { q := strings.TrimSpace(strings.ToLower(m.query)) if q != "" { @@ -55,7 +104,7 @@ func (m Model) pool() []config.Package { } } } - return out + return append(out, m.onlineResults...) } if m.catCur >= 0 && m.catCur < len(m.cats) { return m.cats[m.catCur].Packages @@ -63,6 +112,154 @@ func (m Model) pool() []config.Package { return nil } +// ── online search (debounced openboot.dev lookup while filtering) ── + +// queryChanged resets list position and (re)arms the search debounce for the +// current query. Every call bumps the generation, so in-flight lookups for a +// stale query can never land. +func (m *Model) queryChanged() tea.Cmd { + m.rowCur, m.scroll = 0, 0 + m.onlineResults, m.onlineBusy = nil, false + m.searchSeq++ + q := strings.TrimSpace(m.query) + if len([]rune(q)) < searchMinRunes { + return nil + } + seq := m.searchSeq + return tea.Tick(searchDebounce, func(time.Time) tea.Msg { + return searchTickMsg{seq: seq, query: q} + }) +} + +// clearOnline drops transient search state and invalidates in-flight lookups. +func (m *Model) clearOnline() { + m.onlineResults, m.onlineBusy = nil, false + m.searchSeq++ +} + +func (m Model) onSearchTick(msg searchTickMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.searchSeq || !m.typing || strings.TrimSpace(m.query) != msg.query { + return m, nil // typed past it — a newer generation is armed + } + m.onlineBusy = true + seq := msg.seq + return m, func() tea.Msg { + res, err := searchOnline(msg.query) + if err != nil { + res = nil // offline / API down: quietly show local hits only + } + return searchDoneMsg{seq: seq, results: res} + } +} + +func (m Model) onSearchDone(msg searchDoneMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.searchSeq { + return m, nil + } + m.onlineBusy = false + m.onlineResults = m.filterOnline(msg.results) + for _, p := range m.onlineResults { + m.onlineKnown[p.Name] = true + } + return m, nil +} + +// filterOnline dedupes online hits against the catalog (including earlier +// online picks) and against themselves, and caps the list. +func (m Model) filterOnline(in []config.Package) []config.Package { + seen := map[string]bool{} + for _, c := range m.cats { + for _, p := range c.Packages { + seen[p.Name] = true + } + } + var out []config.Package + for _, p := range in { + if p.Name == "" || seen[p.Name] { + continue + } + seen[p.Name] = true + out = append(out, p) + if len(out) >= onlineMaxHits { + break + } + } + return out +} + +// togglePkg flips selection for p. Online picks are materialised into the +// synthetic "online" sidebar category so they stay visible (and deselectable) +// after the filter clears — they have no home in the local catalog. +func (m *Model) togglePkg(p config.Package) { + if m.isInstalled(p.Name) { + return + } + m.toggle(p.Name) + if !m.onlineKnown[p.Name] { + return + } + if m.selected[p.Name] { + m.addOnlinePick(p) + } else { + m.removeOnlinePick(p.Name) + } +} + +func (m *Model) addOnlinePick(p config.Package) { + for i := range m.cats { + if m.cats[i].Name != onlineCatName { + continue + } + for _, q := range m.cats[i].Packages { + if q.Name == p.Name { + return + } + } + m.cats[i].Packages = append(m.cats[i].Packages, p) + return + } + m.cats = append(m.cats, config.Category{Name: onlineCatName, Packages: []config.Package{p}}) +} + +func (m *Model) removeOnlinePick(name string) { + for i := range m.cats { + if m.cats[i].Name != onlineCatName { + continue + } + kept := m.cats[i].Packages[:0] + for _, q := range m.cats[i].Packages { + if q.Name != name { + kept = append(kept, q) + } + } + m.cats[i].Packages = kept + if len(kept) == 0 { + m.cats = append(m.cats[:i], m.cats[i+1:]...) + if m.catCur >= len(m.cats) { + m.catCur = len(m.cats) - 1 + } + } + return + } +} + +// selectedOnlinePkgs returns the online picks that are currently selected — +// the extra packages the plan needs beyond the catalog selection. +func (m Model) selectedOnlinePkgs() []config.Package { + var out []config.Package + for _, c := range m.cats { + if c.Name != onlineCatName { + continue + } + for _, p := range c.Packages { + if m.selected[p.Name] { + out = append(out, p) + } + } + } + return out +} + // selVisible is the number of package rows the list area can show. It must match // selectList's own count (body height − search row − blank) exactly, or the // scroll clamp and the render disagree: leaving blank rows at the bottom and @@ -98,23 +295,23 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy s := msg.String() if m.typing { + var cmd tea.Cmd switch s { case "esc": m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + m.clearOnline() 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.togglePkg(pool[clamp(m.rowCur, 0, last)]) m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + m.clearOnline() } else { return m.tryInstall() } case "backspace": if len(m.query) > 0 { m.query = trimLast(m.query) - m.rowCur, m.scroll = 0, 0 + cmd = m.queryChanged() } case "up": if m.rowCur > 0 { @@ -129,10 +326,10 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy // (bubbletea coalesces pasted/fast text into one message). if msg.Type == tea.KeyRunes || s == " " { m.query += s - m.rowCur, m.scroll = 0, 0 + cmd = m.queryChanged() } } - return m.clampSelScroll(), nil + return m.clampSelScroll(), cmd } switch s { @@ -143,8 +340,10 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy // Filtering searches across categories, so it's a list operation — // pull focus to the list so ↑↓ behaves predictably after it clears. m.typing, m.query, m.rowCur, m.scroll, m.selFocus = true, "", 0, 0, focusList + m.clearOnline() case "esc": m.query, m.rowCur, m.scroll = "", 0, 0 + m.clearOnline() case "left", "h": m.selFocus = focusCats case "right", "l": @@ -161,10 +360,7 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy m = m.selMoveDown() case " ": if len(pool) > 0 { - p := pool[clamp(m.rowCur, 0, last)] - if !m.isInstalled(p.Name) { - m.toggle(p.Name) - } + m.togglePkg(pool[clamp(m.rowCur, 0, last)]) } case "a": for _, p := range pool { @@ -257,12 +453,13 @@ func (m Model) updateSelectMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.catCur, m.query, m.typing = idx, "", false m.selFocus = focusCats m.rowCur, m.scroll = 0, 0 + m.clearOnline() case hitPkg: m.rowCur = idx m.selFocus = focusList pool := m.pool() - if idx < len(pool) && !m.isInstalled(pool[idx].Name) { - m.toggle(pool[idx].Name) + if idx < len(pool) { + m.togglePkg(pool[idx]) } case hitNone: // click landed on chrome or blank space — nothing to do @@ -419,7 +616,14 @@ func (m Model) selectList(w, h int) []string { } match := "" if strings.TrimSpace(m.query) != "" { - match = fg(cDim3).Render(fmt.Sprintf("%d hits", len(pool))) + hits := fmt.Sprintf("%d hits", len(pool)) + switch { + case m.onlineBusy: + hits += " · " + m.spinner() + " openboot.dev" + case len(m.onlineResults) > 0: + hits += fmt.Sprintf(" · %d from openboot.dev", len(m.onlineResults)) + } + match = fg(cDim3).Render(hits) } rows = append(rows, bar(" "+icon+" "+queryText, match+" ", w)) rows = append(rows, "") @@ -472,8 +676,13 @@ func (m Model) renderRow(p config.Package, cursor bool, w int) string { } tail := fg(cDim3).Render(pkgType(p)) - if installed { + switch { + case installed: tail = fg(cInstalled).Render("installed") + case m.onlineKnown[p.Name]: + // Cyan type badge marks a row sourced from openboot.dev search rather + // than the local catalog. + tail = fg(cInfo).Render(pkgType(p)) } name := padTo(nameStyle.Render(p.Name), 20) diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 2333017..5c228e0 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -11,10 +11,14 @@ import ( func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right string) { switch m.screen { case scrBoot: + cmdline := "~ % openboot install" + if m.srcLabel != "" { + cmdline += " " + m.srcLabel + } if m.probeIdx < len(m.probes) { - return "BOOT", cInfo, "probing this Mac…", "~ % openboot install" + return "BOOT", cInfo, "probing this Mac…", cmdline } - return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", "~ % openboot install" + return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", cmdline case scrSelect: return "SELECT", cAccent, @@ -31,13 +35,13 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s default: // scrInstall if m.done { return "DONE", cAccent, "r replay from boot · q quit", - fmt.Sprintf("%d steps · %ds", m.totalSteps(), m.elapsed()) + fmt.Sprintf("%d steps · %s", m.totalSteps(), fmtElapsed(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()) + fmt.Sprintf("%d/%d · %s", m.completedSteps(), m.totalSteps(), fmtElapsed(m.elapsed())) } return "INSTALL", cWarn, "installing — everything is logged to ~/.openboot/logs", - fmt.Sprintf("%d/%d · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) + fmt.Sprintf("%d/%d · %s", m.completedSteps(), m.totalSteps(), fmtElapsed(m.elapsed())) } } diff --git a/internal/ui/tui/wizard/styles.go b/internal/ui/tui/wizard/styles.go index d252353..1e3ebe1 100644 --- a/internal/ui/tui/wizard/styles.go +++ b/internal/ui/tui/wizard/styles.go @@ -2,6 +2,7 @@ package wizard import ( "strings" + "sync" "github.com/charmbracelet/lipgloss" ) @@ -39,12 +40,33 @@ func fg(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } -// hoverBg paints a hard ANSI true-color background across the whole row. -// lipgloss ends every styled span with a FULL reset (\e[0m), which also clears -// the background — so re-establish the background after each reset, otherwise -// only the first span would be highlighted. cHover = #3d3d4a → rgb(61,61,74). +// cHover is the row-hover background (#3d3d4a in the design). +var cHover = lipgloss.Color("#3d3d4a") + +// hoverBgSeq is the raw background escape for cHover under the terminal's +// actual colour profile, extracted once from a lipgloss render so it +// downsamples on 256/16-colour terminals and disappears entirely (empty +// string) when colour isn't supported — instead of hardcoding a truecolor +// sequence that those terminals would mangle. +var hoverBgSeq = sync.OnceValue(func() string { + const probe = "\x01" + rendered := lipgloss.NewStyle().Background(cHover).Render(probe) + i := strings.Index(rendered, probe) + if i <= 0 { + return "" + } + return rendered[:i] +}) + +// hoverBg paints the hover background across the whole row. lipgloss ends +// every styled span with a FULL reset (\e[0m), which also clears the +// background — so re-establish the background after each reset, otherwise +// only the first span would be highlighted. func hoverBg(s string) string { - const bg = "\x1b[48;2;61;61;74m" + bg := hoverBgSeq() + if bg == "" { + return s // colourless terminal — hover simply has no visual, nothing breaks + } s = strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+bg) return bg + s + "\x1b[0m" } diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 3f8d3ee..4b0b965 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -3,8 +3,12 @@ // 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. +// selector, per-step confirms) and the linear Apply output. Preset installs +// (-p) enter it with the loadout preselected; remote-config installs (slug, +// -u, --from, alias) enter config mode via RunForConfig, with the config's own +// packages on the select screen. Non-interactive paths (--silent, --dry-run, +// --update, no TTY) never reach the wizard; sync-source installs keep their +// linear diff pre-flight and reuse only the live install screen (RunPipeline). package wizard import ( @@ -55,6 +59,13 @@ type Model struct { version string opts *config.InstallOptions + // rc, when non-nil, puts the wizard in config mode (install / -u / + // --from / alias): the select screen shows the config's own packages + // instead of the catalog, probing auto-advances past the loadout question, + // and the plan is built from the filtered config. + rc *config.RemoteConfig + srcLabel string // "user/slug" (or config name) shown in the status bar + width, height int screen screen ticks int // monotonic, drives spinner + elapsed @@ -79,6 +90,12 @@ type Model struct { hoverRow int // mouse hover index in pool(), -1 when not on a package row selected map[string]bool + // ── select: online search (packages beyond the local catalog) ── + searchSeq int // debounce generation; stale ticks/results are dropped + onlineBusy bool // a search request is in flight + onlineResults []config.Package // current query's online hits (deduped vs catalog) + onlineKnown map[string]bool // names sourced from openboot.dev, for the row badge + // ── git identity (captured only when none is configured) ── gitName string gitEmail string @@ -103,6 +120,7 @@ type Model struct { done bool installErr error skippedPkgs int // terminal events with SkipDetail (already installed) + failedPkgs []string // packages whose terminal event was StepFail, for the completion summary terminalSeen map[string]bool // packages that produced a terminal event, for skip dedup (npm retry) installTick int // ticks value when install started, for elapsed cancel context.CancelFunc @@ -124,12 +142,40 @@ func New(version string, opts *config.InstallOptions) Model { cats: config.GetCategories(), hoverRow: -1, selected: map[string]bool{}, + onlineKnown: map[string]bool{}, events: make(chan tea.Msg, 1024), installDone: make(chan struct{}), terminalSeen: map[string]bool{}, } } +// NewForConfig builds a wizard model for a remote-config install: the sidebar +// categories are the config's own package lists, everything preselected — +// review-and-prune, mirroring the config's declarative intent. +func NewForConfig(version string, opts *config.InstallOptions, rc *config.RemoteConfig) Model { + m := New(version, opts) + m.rc = rc + m.srcLabel = configLabel(rc) + m.cats = categoriesFromConfig(rc) + for _, c := range m.cats { + for _, p := range c.Packages { + m.selected[p.Name] = true + } + } + return m +} + +// configLabel names a remote config for display: user/slug when known. +func configLabel(rc *config.RemoteConfig) string { + if rc.Username != "" && rc.Slug != "" { + return rc.Username + "/" + rc.Slug + } + if rc.Name != "" { + return rc.Name + } + return "config" +} + func (m Model) Init() tea.Cmd { if m.pipelineRun != nil { // waitForEvent is essential: it drains m.events into Update. Without it @@ -171,25 +217,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if msg.String() == "ctrl+c" { - // First ctrl+c during a running install requests an abort: cancel - // the context and keep the TUI up until the engine reports back - // (installDoneMsg), so the goroutine is joined and the abort is - // reported honestly. A second ctrl+c force-quits. - if m.screen == scrInstall && m.installing && !m.aborting { - m.aborting = true - if m.cancel != nil { - m.cancel() - } - return m, nil - } - if m.cancel != nil { - m.cancel() - } - if m.installing { - m.installErr = ErrAborted - } - m.quit = true - return m, tea.Quit + return m.onCtrlC() } switch m.screen { case scrBoot: @@ -210,12 +238,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case probeDoneMsg: return m.onProbeDone(msg) + case searchTickMsg: + return m.onSearchTick(msg) + + case searchDoneMsg: + return m.onSearchDone(msg) + case evMsg, reporterMsg, installDoneMsg: return m.onInstallEvent(msg) } return m, nil } +// onCtrlC implements the global abort semantics. The 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; outside an +// install it quits immediately. +func (m Model) onCtrlC() (tea.Model, tea.Cmd) { + 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 +} + // routeMouse dispatches a mouse event to the active screen's handler. func (m Model) routeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { switch m.screen { @@ -239,10 +296,21 @@ func (m Model) spinner() string { // ── View / chrome ────────────────────────────────────────────────────────── +// minTermW/minTermH are the smallest terminal the layout renders legibly in: +// below this the two-pane screens truncate into garbage, so show a resize +// hint instead of a broken frame. Keys (q / ctrl+c) still work. +const ( + minTermW = 60 + minTermH = 15 +) + func (m Model) View() string { if m.width == 0 || m.height == 0 { return "" } + if m.width < minTermW || m.height < minTermH { + return m.smallTermView() + } bodyH := m.height - 2 if bodyH < 1 { bodyH = 1 @@ -265,6 +333,22 @@ func (m Model) View() string { return m.titleBar() + "\n" + fitBlock(body, m.width, bodyH) + "\n" + m.statusBar() } +// smallTermView replaces the whole frame when the terminal is smaller than +// the layout can survive. Plain short lines so it stays legible at any size; +// ctrl+c (handled globally) still quits. +func (m Model) smallTermView() string { + lines := []string{ + "", + " " + fg(cAccent).Render("▲") + " " + fg(cTextHi).Render("openboot"), + "", + " " + fg(cWarn).Render(fmt.Sprintf("terminal too small — %d×%d", m.width, m.height)), + " " + fg(cMuted).Render(fmt.Sprintf("resize to at least %d×%d to continue", minTermW, minTermH)), + "", + " " + fg(cDim2).Render("ctrl+c quit"), + } + return fitBlock(strings.Join(lines, "\n"), m.width, m.height) +} + // inBody reports whether screen row y is inside the rendered body (rows // 1..height-2; row 0 is the title bar and height-1 the status bar). Mouse // hit-tests guard on it so a click on the chrome never maps to a body row — @@ -378,8 +462,19 @@ func truncCell(s string, w int) string { // 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) + return runProgram(New(version, opts)) +} + +// RunForConfig launches the wizard in config mode for a fetched remote config +// (install / -u / --from / alias): boot probe → select (the config's +// packages, preselected) → review → live install. Returns like Run; the +// returned plan keeps the config's post-install script for the CLI to run +// after teardown (the alt-screen can't host its confirm prompt). +func RunForConfig(version string, opts *config.InstallOptions, rc *config.RemoteConfig) (plan installer.InstallPlan, confirmed bool, err error) { + return runProgram(NewForConfig(version, opts, rc)) +} +func runProgram(m Model) (plan installer.InstallPlan, confirmed bool, err error) { realOut, restore := redirectOutput() defer restore() diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 3b24721..180decc 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -636,7 +636,7 @@ func TestInstallGoroutineStreamsToDone(t *testing.T) { m := New("1", opts) m.screen = scrInstall - plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) plan.Silent = true m.plan = plan m.phases = buildPhases(plan) @@ -777,3 +777,138 @@ func TestPhasesForPlan(t *testing.T) { require.Contains(t, byName, "Dotfiles") assert.NotContains(t, byName, "npm globals", "no npm in this plan") } + +// ── deep-polish additions: small terminals, preset entry, online search, +// completion summary, hover colour degradation ── + +func TestSmallTerminalShowsResizeHint(t *testing.T) { + m := sized(48, 12) + v := m.View() + assert.Contains(t, v, "terminal too small") + assert.Contains(t, v, "resize to at least 60×15") + + // Growing the window restores the real frame. + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + assert.NotContains(t, m.View(), "terminal too small") +} + +func TestPresetOptionAutoAdvancesToSelect(t *testing.T) { + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0", Preset: "developer"}) + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + m = finishProbes(m) + require.Equal(t, scrSelect, m.screen, "-p skips the loadout question") + assert.Equal(t, config.GetPackagesForPreset("developer"), m.selected) +} + +func TestUnknownPresetStaysOnBoot(t *testing.T) { + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0", Preset: "bogus"}) + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + m = finishProbes(m) + assert.Equal(t, scrBoot, m.screen) +} + +func TestOnlineSearchFindsTogglesAndSurvivesFilterClear(t *testing.T) { + restore := searchOnline + searchOnline = func(string) ([]config.Package, error) { + return []config.Package{ + {Name: "web-only-tool", Description: "only on openboot.dev", IsNpm: true}, + {Name: "curl", Description: "already in the catalog"}, // must be deduped + }, nil + } + defer func() { searchOnline = restore }() + + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + m = send(m, key("/")) + for _, r := range "web-only" { + m = send(m, key(string(r))) + } + seq := m.searchSeq + + // A stale generation must be ignored outright. + m = send(m, searchDoneMsg{seq: seq - 1, results: []config.Package{{Name: "stale"}}}) + require.Empty(t, m.onlineResults) + + // The debounce tick for the live generation arms the lookup… + next, cmd := m.Update(searchTickMsg{seq: seq, query: strings.TrimSpace(m.query)}) + m = next.(Model) + require.True(t, m.onlineBusy) + require.NotNil(t, cmd) + // …whose result lands as a searchDoneMsg (stub is synchronous). + m = send(m, cmd().(searchDoneMsg)) + require.False(t, m.onlineBusy) + require.Len(t, m.onlineResults, 1, "catalog dupes are filtered out") + + // The online hit is in the pool; enter toggles the cursor row, clears the + // filter, and the pick survives in the synthetic online category. + pool := m.pool() + idx := -1 + for i, p := range pool { + if p.Name == "web-only-tool" { + idx = i + } + } + require.GreaterOrEqual(t, idx, 0, "online hit joins the filtered pool") + m.rowCur = idx + m = send(m, key("enter")) + assert.True(t, m.selected["web-only-tool"]) + assert.Empty(t, m.query, "enter-toggle clears the filter") + + foundCat := false + for _, c := range m.cats { + if c.Name == onlineCatName { + foundCat = true + assert.Len(t, c.Packages, 1) + } + } + require.True(t, foundCat, "online pick is homed in the sidebar category") + + online := m.selectedOnlinePkgs() + require.Len(t, online, 1) + assert.True(t, online[0].IsNpm, "type info survives for categorization") + + // Deselecting removes the pick and the now-empty category. + m.catCur = len(m.cats) - 1 + m.rowCur = 0 + m = send(m, key("space")) + assert.False(t, m.selected["web-only-tool"]) + for _, c := range m.cats { + assert.NotEqual(t, onlineCatName, c.Name, "empty online category is dropped") + } + assert.Less(t, m.catCur, len(m.cats), "category cursor re-clamped") +} + +func TestCompletionSummaryListsFailures(t *testing.T) { + m := installFrame(sized(96, 30), 96, 30) + m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "ripgrep", Status: progress.StepFail, Detail: "build error"}}) + m = send(m, installDoneMsg{err: errors.New("1 package failed")}) + require.True(t, m.done) + + var logText strings.Builder + for _, l := range m.logs { + logText.WriteString(l.text + "\n") + } + assert.Contains(t, logText.String(), "installed · 0 already present", "summary counts land in the log") + assert.Contains(t, logText.String(), "1 failed: ripgrep", "failed package names are restated at the end") +} + +func TestCompletionSummaryCleanRun(t *testing.T) { + m := installFrame(sized(96, 30), 96, 30) + m = send(m, installDoneMsg{}) + var logText strings.Builder + for _, l := range m.logs { + logText.WriteString(l.text + "\n") + } + assert.Contains(t, logText.String(), "installed ·") + assert.NotContains(t, logText.String(), "failed:") +} + +func TestHoverBgFollowsColorProfile(t *testing.T) { + // Under `go test` stdout is a pipe, so the lipgloss profile is usually + // colourless — hover must become a no-op, never a hardcoded escape. + if seq := hoverBgSeq(); seq == "" { + assert.Equal(t, "row", hoverBg("row")) + } else { + assert.True(t, strings.HasPrefix(hoverBg("row"), seq)) + } +}