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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

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.
Expand Down
63 changes: 58 additions & 5 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/cli/wizard_routing_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
60 changes: 57 additions & 3 deletions internal/installer/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,18 @@ 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.
//
// 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}
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,
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 43 additions & 3 deletions internal/installer/plan_selection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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)
}
3 changes: 2 additions & 1 deletion internal/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 6 additions & 3 deletions internal/npm/streaming.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
6 changes: 3 additions & 3 deletions internal/npm/streaming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
14 changes: 14 additions & 0 deletions internal/ui/tui/wizard/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading