Skip to content

Commit a158b53

Browse files
authored
feat(tui): wizard deep-polish — online search, preset & config-mode entry, summary (#149)
* fix(npm): carry per-package install durations in streaming events Sequential npm installs now time each package and emit the duration as the StepOK detail, so the wizard log shows npm rows with the same timing brew rows get (npm batch installs have no per-package timing to report, so those rows stay clean rather than showing invented numbers). * feat(tui): wizard deep-polish — online search, preset entry, summary, small terminals Closes the UX gaps found in the v0.63 comparison: - select screen regains online search (lost in the Redesign v5 rewrite): filtering fires a debounced openboot.dev lookup; hits are deduped against the catalog, badge-marked, and picks are homed in a synthetic 'online' sidebar category so they survive clearing the filter. PlanFromSelection gains an online parameter so their type info reaches categorization. - 'install -p <preset>' (and OPENBOOT_PRESET) on a TTY now enters the full wizard with the loadout preselected on the select screen, instead of the linear pre-flight + pipeline-screen stitch. Unknown presets keep the linear path and its clear error. - completion appends a summary to the log tail — counts plus the names of failed packages, which had scrolled away by the DONE screen. - elapsed times render as 3m24s instead of raw seconds everywhere. - terminals below 60x15 get a resize hint instead of a broken frame. - the hover background derives its escape from the active lipgloss colour profile, so it downsamples on 256/16-colour terminals and disappears cleanly without colour support. * feat(tui): run slug/--from/-u installs through the full wizard (config mode) Remote-config installs on a TTY previously got a stitched experience: linear 3-way prompt + the old full-screen customizer, then a jump into the wizard's live install screen. They now enter the wizard proper via RunForConfig: boot probe → select (the config's own packages as sidebar categories, all preselected, installed tools marked) → review plan → live install. - installer.PlanForRemoteSelection builds the plan from the config filtered to the wizard selection, plus openboot.dev search picks; everything declarative (git, shell, dotfiles, prefs, dock, login items, post-install) flows through planFromRemoteConfig unchanged. - config mode never interposes the git screen — the git step already keeps an existing config and no-ops when absent, matching the old slug pipeline behavior; the review screen says so instead of rendering an empty identity. - post-install can't host its confirm inside the alt-screen: the review screen surfaces it as an informational row, the streamed apply strips it, and the CLI runs it after teardown from the returned plan (mirroring the pipeline path's R1 fix). - sync-source installs are unchanged (diff pre-flight + RunPipeline); --pick, --silent, --dry-run, --update and non-TTY keep their flows. - promptCustomizeAndApply + RunConfigCustomizer remain for the dry-run/update/non-TTY prompt paths and the sync customizer. * refactor(tui): extract onCtrlC to keep Model.Update under the gocyclo gate The two online-search message cases pushed Update to complexity 22 (gate is 20). Moving the ctrl+c abort semantics into their own method reads better than a nolint and drops Update back under the gate.
1 parent 6059a26 commit a158b53

18 files changed

Lines changed: 918 additions & 83 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
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).
66

77
Entry point: `cmd/openboot/main.go``internal/cli.Execute()`.
8-
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.
8+
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 <preset>` 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.
99

1010
For full contribution guide (test layering L1–L4, Runner interface, hook setup) see @CONTRIBUTING.md.
1111
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.

internal/cli/install.go

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo /
136136
return perr
137137
}
138138
installCfg.RemoteConfig = rc
139+
} else if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() {
140+
// The full-screen config wizard owns the whole interactive flow
141+
// (select the config's packages → review → live install) —
142+
// replacing the linear 3-way prompt + customizer that used to
143+
// precede the pipeline screen for slug / -u / --from / alias.
144+
return runConfigWizard(installCfg.RemoteConfig)
139145
} else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) {
140146
rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig)
141147
if err != nil {
@@ -168,15 +174,33 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo /
168174
return err
169175
}
170176

171-
// shouldLaunchWizard reports whether this run is a bare interactive
172-
// `openboot install` on a TTY — the case the full-screen wizard (boot probe →
173-
// select → live install) owns. Explicit sources (-p, --from, -u, sync),
174-
// --silent, --dry-run, and --update keep their existing flows.
177+
// shouldLaunchWizard reports whether this run gets the full-screen wizard
178+
// (boot probe → select → live install) on a TTY: a bare `openboot install`,
179+
// or a valid preset (-p / OPENBOOT_PRESET / positional), which enters the
180+
// wizard with that loadout preselected on the select screen. Remote sources
181+
// (--from, -u, slug, sync), --silent, --dry-run, and --update keep their
182+
// existing flows.
175183
func shouldLaunchWizard(src *installSource) bool {
176-
return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun &&
184+
return wizardSource(src) && !installCfg.Silent && !installCfg.DryRun &&
177185
!installCfg.Update && system.HasTTY()
178186
}
179187

188+
// wizardSource is the source-kind half of the wizard-routing decision, split
189+
// from the TTY/mode checks so it's testable without a terminal.
190+
func wizardSource(src *installSource) bool {
191+
switch src.kind {
192+
case sourceNone:
193+
return true
194+
case sourcePreset:
195+
// An unknown preset must keep the linear path, which rejects it with a
196+
// clear error instead of silently opening an empty wizard.
197+
_, ok := config.GetPreset(installCfg.Preset)
198+
return ok
199+
default:
200+
return false
201+
}
202+
}
203+
180204
// runPipelineInstall resolves the plan (with linear pre-flight prep) and applies
181205
// it through the wizard's live pipeline screen, so a slug/preset/RemoteConfig
182206
// install streams like the wizard. ApplyContext is the same engine RunContext
@@ -256,6 +280,35 @@ func runInstallWizard() error {
256280
return nil
257281
}
258282

283+
// runConfigWizard launches the full-screen wizard in config mode for a fetched
284+
// remote config. Follow-ups the alt-screen can't host — the post-install
285+
// script (needs a preview + confirm) and the screen-recording reminder — run
286+
// here afterwards, on a normal terminal, mirroring runPipelineInstall's order.
287+
func runConfigWizard(rc *config.RemoteConfig) error {
288+
opts := installCfg.ToInstallOptions()
289+
plan, confirmed, err := wizard.RunForConfig(installCfg.Version, opts, rc)
290+
if errors.Is(err, wizard.ErrAborted) || errors.Is(err, context.Canceled) {
291+
return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs")
292+
}
293+
if !confirmed {
294+
if err != nil {
295+
return fmt.Errorf("install wizard: %w", err)
296+
}
297+
ui.Info("Cancelled.")
298+
return nil
299+
}
300+
if err == nil && len(plan.PostInstall) > 0 {
301+
if piErr := installer.RunPostInstallAfterTUI(plan); piErr != nil {
302+
ui.Error(fmt.Sprintf("Post-install script failed: %v", piErr))
303+
}
304+
}
305+
installer.ShowScreenRecordingReminderAfterTUI(plan)
306+
if err == nil {
307+
saveSyncSourceIfRemote(installCfg)
308+
}
309+
return err
310+
}
311+
259312
// ── Source resolution ─────────────────────────────────────────────────────────
260313

261314
type sourceKind int
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// wizardSource is the source-kind half of shouldLaunchWizard, split out so the
10+
// routing is testable without a TTY: bare installs and valid presets get the
11+
// full wizard; unknown presets and remote sources keep the linear path.
12+
func TestWizardSource(t *testing.T) {
13+
oldPreset := installCfg.Preset
14+
defer func() { installCfg.Preset = oldPreset }()
15+
16+
assert.True(t, wizardSource(&installSource{kind: sourceNone}), "bare install")
17+
18+
installCfg.Preset = "developer"
19+
assert.True(t, wizardSource(&installSource{kind: sourcePreset}), "valid preset")
20+
21+
installCfg.Preset = "not-a-preset"
22+
assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset keeps the linear error path")
23+
24+
assert.False(t, wizardSource(&installSource{kind: sourceCloud}), "cloud config")
25+
assert.False(t, wizardSource(&installSource{kind: sourceSyncSource}), "sync source")
26+
assert.False(t, wizardSource(&installSource{kind: sourceFile}), "local file")
27+
}

internal/installer/plan.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,16 +309,18 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error)
309309
}
310310

311311
// PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit
312-
// package selection gathered by the install TUI. It applies system-config
312+
// package selection gathered by the install TUI. online carries packages the
313+
// wizard picked from openboot.dev search — they aren't in the local catalog,
314+
// so categorization needs their type info. It applies system-config
313315
// defaults (existing git identity, oh-my-zsh, dotfiles, macOS prefs) without
314316
// any interactive prompts — all interaction already happened in the wizard.
315317
//
316318
// Git identity is reused from the existing global git config when present; when
317319
// absent the git step is skipped rather than prompting, since the TUI has no
318320
// name/email screen. CLI overrides (--packages-only, --shell/--macos/--dotfiles
319321
// skip) are still honored via opts.
320-
func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) InstallPlan {
321-
st := &config.InstallState{SelectedPkgs: selected}
322+
func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool, online []config.Package) InstallPlan {
323+
st := &config.InstallState{SelectedPkgs: selected, OnlinePkgs: online}
322324

323325
plan := InstallPlan{
324326
Version: opts.Version,
@@ -327,6 +329,7 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) In
327329
PackagesOnly: opts.PackagesOnly,
328330
AllowPostInstall: opts.AllowPostInstall,
329331
SelectedPkgs: selected,
332+
OnlinePkgs: online,
330333
}
331334

332335
cats := categorizeSelectedPackages(opts, st)
@@ -366,6 +369,57 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) In
366369
return plan
367370
}
368371

372+
// PlanForRemoteSelection builds a ready-to-Apply InstallPlan from a remote
373+
// config filtered to the wizard's package selection, plus any openboot.dev
374+
// search picks made on the select screen. It is the config-mode counterpart
375+
// of PlanFromSelection: everything non-package (git, dotfiles, shell, macOS
376+
// prefs, dock, login items, post-install) comes from the remote config via
377+
// planFromRemoteConfig, exactly like the declarative slug path — no prompts.
378+
func PlanForRemoteSelection(opts *config.InstallOptions, rc *config.RemoteConfig, selected map[string]bool, online []config.Package) InstallPlan {
379+
f := *rc
380+
f.Packages = filterEntriesBySelection(rc.Packages, selected)
381+
f.Casks = filterEntriesBySelection(rc.Casks, selected)
382+
f.Npm = filterEntriesBySelection(rc.Npm, selected)
383+
384+
plan := InstallPlan{
385+
Version: opts.Version,
386+
DryRun: opts.DryRun,
387+
Silent: opts.Silent,
388+
PackagesOnly: opts.PackagesOnly,
389+
AllowPostInstall: opts.AllowPostInstall,
390+
RemoteConfig: &f,
391+
}
392+
st := &config.InstallState{RemoteConfig: &f}
393+
planFromRemoteConfig(opts, st, &plan)
394+
395+
for _, p := range online {
396+
if !selected[p.Name] || plan.SelectedPkgs[p.Name] {
397+
continue
398+
}
399+
switch {
400+
case p.IsNpm:
401+
plan.Npm = append(plan.Npm, p.Name)
402+
case p.IsCask:
403+
plan.Casks = append(plan.Casks, p.Name)
404+
default:
405+
plan.Formulae = append(plan.Formulae, p.Name)
406+
}
407+
plan.SelectedPkgs[p.Name] = true
408+
plan.OnlinePkgs = append(plan.OnlinePkgs, p)
409+
}
410+
return plan
411+
}
412+
413+
func filterEntriesBySelection(in config.PackageEntryList, selected map[string]bool) config.PackageEntryList {
414+
out := make(config.PackageEntryList, 0, len(in))
415+
for _, e := range in {
416+
if selected[e.Name] {
417+
out = append(out, e)
418+
}
419+
}
420+
return out
421+
}
422+
369423
// PlanFromSnapshot builds an InstallPlan from snapshot state without any interactive
370424
// prompts. All decisions are derived from st.Snapshot* fields and opts.
371425
func PlanFromSnapshot(opts *config.InstallOptions, st *config.InstallState) InstallPlan {

internal/installer/plan_selection_test.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) {
1414
sel := config.GetPackagesForPreset("developer")
1515
require.NotEmpty(t, sel)
1616

17-
plan := PlanFromSelection(opts, sel)
17+
plan := PlanFromSelection(opts, sel, nil)
1818

1919
assert.Equal(t, "1.0.0", plan.Version)
2020
assert.Positive(t, len(plan.Formulae)+len(plan.Casks)+len(plan.Npm), "packages categorized")
@@ -35,7 +35,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) {
3535

3636
func TestPlanFromSelectionPackagesOnly(t *testing.T) {
3737
opts := &config.InstallOptions{PackagesOnly: true}
38-
plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"))
38+
plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil)
3939

4040
assert.True(t, plan.PackagesOnly)
4141
assert.False(t, plan.InstallOhMyZsh)
@@ -46,9 +46,49 @@ func TestPlanFromSelectionPackagesOnly(t *testing.T) {
4646

4747
func TestPlanFromSelectionSkipFlags(t *testing.T) {
4848
opts := &config.InstallOptions{Shell: "skip", Dotfiles: "skip", Macos: "skip"}
49-
plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"))
49+
plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil)
5050

5151
assert.False(t, plan.InstallOhMyZsh)
5252
assert.Empty(t, plan.DotfilesURL)
5353
assert.Empty(t, plan.MacOSPrefs)
5454
}
55+
56+
// Online picks come from openboot.dev search — not in the local catalog, so
57+
// their type info must flow through OnlinePkgs into categorization.
58+
func TestPlanFromSelectionIncludesOnlinePicks(t *testing.T) {
59+
opts := &config.InstallOptions{PackagesOnly: true}
60+
online := []config.Package{{Name: "web-only-tool", IsNpm: true}}
61+
plan := PlanFromSelection(opts, map[string]bool{"web-only-tool": true}, online)
62+
assert.Contains(t, plan.Npm, "web-only-tool")
63+
assert.Equal(t, online, plan.OnlinePkgs)
64+
}
65+
66+
// Config-mode plans: the remote config filtered by the wizard's selection,
67+
// with openboot.dev picks appended and all declarative fields carried.
68+
func TestPlanForRemoteSelection(t *testing.T) {
69+
rc := &config.RemoteConfig{
70+
Packages: config.PackageEntryList{{Name: "cowsay"}, {Name: "fortune"}},
71+
Casks: config.PackageEntryList{{Name: "warp"}},
72+
Npm: config.PackageEntryList{{Name: "left-pad"}},
73+
Taps: []string{"acme/tap"},
74+
DotfilesRepo: "https://github.com/alice/dotfiles",
75+
Shell: &config.RemoteShellConfig{OhMyZsh: true, Theme: "agnoster", Plugins: []string{"git"}},
76+
PostInstall: []string{"echo hi"},
77+
}
78+
sel := map[string]bool{"cowsay": true, "warp": true, "web-x": true} // fortune & left-pad deselected
79+
online := []config.Package{{Name: "web-x", IsNpm: true}}
80+
81+
plan := PlanForRemoteSelection(&config.InstallOptions{}, rc, sel, online)
82+
83+
assert.Equal(t, []string{"cowsay"}, plan.Formulae)
84+
assert.Equal(t, []string{"warp"}, plan.Casks)
85+
assert.Equal(t, []string{"web-x"}, plan.Npm, "online pick lands with its npm type")
86+
assert.Equal(t, []string{"acme/tap"}, plan.Taps, "taps ride along untouched")
87+
assert.Equal(t, "https://github.com/alice/dotfiles", plan.DotfilesURL)
88+
assert.True(t, plan.InstallOhMyZsh)
89+
assert.Equal(t, "agnoster", plan.ShellTheme)
90+
assert.Equal(t, []string{"echo hi"}, plan.PostInstall)
91+
assert.True(t, plan.SelectedPkgs["web-x"])
92+
assert.False(t, plan.SelectedPkgs["fortune"])
93+
assert.Equal(t, online, plan.OnlinePkgs)
94+
}

internal/npm/npm.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,9 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [
250250

251251
for _, pkg := range remaining {
252252
npmStepStart(bar, pkg)
253+
start := time.Now()
253254
errMsg := installNpmPackageWithRetryContext(ctx, pkg)
254-
npmStepDone(bar, pkg, errMsg == "", errMsg)
255+
npmStepDone(bar, pkg, errMsg == "", errMsg, ui.FormatDuration(time.Since(start)))
255256
if errMsg != "" {
256257
failed = append(failed, pkg)
257258
}

internal/npm/streaming.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ func npmStepStart(bar *ui.StickyProgress, name string) {
4343
}
4444

4545
// npmStepDone reports the result of a single npm package install, preserving
46-
// the exact console output when not streaming.
47-
func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg string) {
46+
// the exact console output when not streaming. duration is the measured
47+
// install time (e.g. "2.1s") carried as the success Detail so streaming
48+
// renderers show npm rows with the same timing brew rows get; empty means
49+
// no per-package timing is available.
50+
func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg, duration string) {
4851
if streaming() {
4952
if ok {
50-
progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK})
53+
progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK, Detail: duration})
5154
} else {
5255
progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepFail, Detail: errMsg})
5356
}

internal/npm/streaming_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ func TestNpmStepHelpersEmitWhenStreaming(t *testing.T) {
2626

2727
assert.NotPanics(t, func() {
2828
npmStepStart(nil, "typescript")
29-
npmStepDone(nil, "typescript", true, "")
30-
npmStepDone(nil, "eslint", false, "E404")
29+
npmStepDone(nil, "typescript", true, "", "2.1s")
30+
npmStepDone(nil, "eslint", false, "E404", "0.3s")
3131
})
3232

3333
require.Len(t, got, 3)
3434
assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepStart, Command: "npm install -g typescript"}, got[0])
35-
assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepOK}, got[1])
35+
assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepOK, Detail: "2.1s"}, got[1])
3636
assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "eslint", Status: progress.StepFail, Detail: "E404"}, got[2])
3737
}

internal/ui/tui/wizard/boot.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ func (m Model) onProbeDone(msg probeDoneMsg) (tea.Model, tea.Cmd) {
101101
if m.probeIdx < len(m.probes) {
102102
return m, m.runProbe(m.probeIdx)
103103
}
104+
// Config mode: the remote config already answers the loadout question —
105+
// go straight to reviewing its (preselected) packages.
106+
if m.rc != nil {
107+
return m.enterSelect(m.selected)
108+
}
109+
// A preset given on the CLI (-p / OPENBOOT_PRESET) already answers the
110+
// loadout question — skip straight to the select screen with it applied,
111+
// so the flag means "start from this loadout, review, install" instead of
112+
// bypassing the wizard entirely.
113+
if m.opts.Preset != "" {
114+
if _, ok := config.GetPreset(m.opts.Preset); ok {
115+
return m.enterSelect(config.GetPackagesForPreset(m.opts.Preset))
116+
}
117+
}
104118
return m, nil // probing complete; wait for a loadout choice
105119
}
106120

0 commit comments

Comments
 (0)