Skip to content
Merged
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
5 changes: 3 additions & 2 deletions 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` runs a 7-step wizard in `internal/installer/installer.go`.
Core flow: `openboot install` orchestrates plan → apply in `internal/installer/installer.go`. Bare interactive `openboot install` on a TTY runs the full-screen install TUI in `internal/ui/tui/wizard/` (boot probe → select → live install); explicit sources (`-p`, `--from`, `-u`, sync), `--silent`, and `--dry-run` use the linear flow.

For full contribution guide (test layering L1–L4, Runner interface, hook setup) see @CONTRIBUTING.md.
For AI agents: @AGENTS.md indexes invariants enforced by `internal/archtest`; @docs/HARNESS.md is the steering meta-doc for where to encode new rules.
Expand Down Expand Up @@ -75,7 +75,8 @@ scripts/
| Task | Location | Notes |
|------|----------|-------|
| Add CLI command | `internal/cli/` | Register in `root.go init()`, follow cobra pattern |
| Change install flow | `internal/installer/installer.go` | 7-step wizard orchestrator |
| Change install flow | `internal/installer/installer.go` | plan → apply orchestrator; `PlanFromSelection` builds a plan from TUI picks |
| Change interactive install TUI | `internal/ui/tui/wizard/` | Redesign v5: boot/select/install screens; live install streams `internal/progress` events (brew/npm `SetProgressSink`) |
| Change sync behavior | `internal/sync/diff.go`, `internal/sync/plan.go` | Diff → confirm → execute |
| Add package category | `openboot.dev/src/lib/package-metadata.ts` | Server is source of truth; CLI fetches `/api/packages` and caches 24h in `~/.openboot/packages-cache.json`. `data/packages.yaml` is fallback only. |
| Modify presets | `internal/config/data/presets.yaml` | 3 presets: minimal, developer, full |
Expand Down
1 change: 1 addition & 0 deletions docs/HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Three regulation categories:
| Behav. | L2 contract schema (against openboot-contract repo) | CI | `.github/workflows/test.yml` `contract` job |
| Behav. | L3 e2e binary | release | `make test-e2e` |
| Behav. | L4 VM e2e (`vm`) — full destructive suite on a clean macOS host | every PR | `.github/workflows/vm-e2e-spike.yml` (macos-14 runner, two parallel jobs) |
| Behav. | Install-wizard TUI on a real pty — L3: launch/quit smoke + full keyboard choreography (stops before confirm, installs nothing); L4: same key sequence through a real install via `expect(1)`, asserting brew/git system state | L3 at release, L4 every PR | `test/e2e/install_wizard_e2e_test.go`, `test/e2e/install_wizard_vm_test.go` |
| Behav. | curl\|bash smoke (install.sh + mock server) | every PR | `.github/workflows/test.yml` `curl-bash-smoke` job |
| Behav. | Auto-release sensor — patch fast lane (`fix:`-only) auto-tags + dispatches `release.yml`; feat threshold opens a `release-ready` issue (check L4 CI green, then tag manually) | push to `main` | `.github/workflows/auto-release.yml` |
| Behav. | Release notes — Conventional Commits since previous tag, grouped by type (Features / Bug Fixes / etc) + Full Changelog link, appended to the install-instructions template | tag push or `workflow_dispatch` | `.github/workflows/release.yml` (`Write release notes` step) |
Expand Down
4 changes: 2 additions & 2 deletions internal/archtest/baseline/no-direct-exec.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Each line is <file>:<line> of a known existing violation.
# Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/...
internal/auth/login.go:195
internal/brew/brew_install.go:327
internal/brew/brew_install.go:356
internal/cli/snapshot.go:22
internal/diff/compare.go:247
internal/diff/compare.go:253
Expand All @@ -12,7 +12,7 @@ internal/dotfiles/dotfiles.go:66
internal/dotfiles/dotfiles.go:351
internal/dotfiles/dotfiles.go:449
internal/installer/step_system.go:132
internal/npm/npm.go:22
internal/npm/npm.go:23
internal/permissions/screen_recording_cgo.go:21
internal/shell/shell.go:184
internal/updater/updater.go:205
Expand Down
86 changes: 61 additions & 25 deletions internal/brew/brew_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

progresspkg "github.com/openbootdotdev/openboot/internal/progress"
"github.com/openbootdotdev/openboot/internal/system"
"github.com/openbootdotdev/openboot/internal/ui"
)
Expand Down Expand Up @@ -109,23 +110,27 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun
// Casks don't have an alias system, so we skip resolution for them.
aliasMap := ResolveFormulaNames(cliPkgs)

var newCli []string
var newCli, skippedCli []string
for _, p := range cliPkgs {
resolvedName := aliasMap[p]
if !alreadyFormulae[resolvedName] {
newCli = append(newCli, p)
} else {
installedFormulae = append(installedFormulae, resolvedName)
skippedCli = append(skippedCli, p)
}
}
var newCask []string
var newCask, skippedCask []string
for _, p := range caskPkgs {
if !alreadyCasks[p] {
newCask = append(newCask, p)
} else {
installedCasks = append(installedCasks, p)
skippedCask = append(skippedCask, p)
}
}
// Streaming invariant: skipped packages still produce a terminal event.
EmitSkipped(skippedCli, skippedCask)

skipped := total - len(newCli) - len(newCask)
if skipped > 0 {
Expand All @@ -142,14 +147,19 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun
return installedFormulae, installedCasks, preErr
}

progress := ui.NewStickyProgress(len(newCli) + len(newCask))
progress.SetSkipped(skipped)
progress.Start()
// bar stays nil when a streaming sink is registered — the sink owns the
// terminal, so we emit events instead of drawing a sticky progress bar.
var bar *ui.StickyProgress
if !streaming() {
bar = ui.NewStickyProgress(len(newCli) + len(newCask))
bar.SetSkipped(skipped)
bar.Start()
}

var allFailed []failedJob

if len(newCli) > 0 {
failed := runSerialInstallWithProgress(ctx, newCli, progress)
failed := runSerialInstallWithProgress(ctx, newCli, bar)
failedSet := make(map[string]bool, len(failed))
for _, f := range failed {
failedSet[f.name] = true
Expand All @@ -163,12 +173,14 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun
}

if len(newCask) > 0 {
caskInstalled, caskFailed := installCasksWithProgress(ctx, newCask, progress)
caskInstalled, caskFailed := installCasksWithProgress(ctx, newCask, bar)
installedCasks = append(installedCasks, caskInstalled...)
allFailed = append(allFailed, caskFailed...)
}

progress.Finish()
if bar != nil {
bar.Finish()
}

allFailed = retryFailedJobs(ctx, allFailed, &installedFormulae, &installedCasks, aliasMap)

Expand All @@ -186,22 +198,21 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun
}

// installCasksWithProgress installs cask packages one by one with brew output
// suppressed. Returns successful installs and failed jobs.
func installCasksWithProgress(ctx context.Context, pkgs []string, progress *ui.StickyProgress) (installed []string, failed []failedJob) {
// suppressed. Returns successful installs and failed jobs. bar is nil when a
// streaming progress sink is registered.
func installCasksWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) (installed []string, failed []failedJob) {
for _, pkg := range pkgs {
progress.SetCurrent(pkg)
stepStart(bar, progresspkg.PhaseApplications, pkg, "brew install --cask "+pkg)

start := time.Now()
errMsg := installCaskWithProgress(ctx, pkg)
elapsed := time.Since(start)

progress.IncrementWithStatus(errMsg == "")
duration := ui.FormatDuration(elapsed)
stepDone(bar, progresspkg.PhaseApplications, pkg, errMsg == "", errMsg, duration)
if errMsg == "" {
progress.PrintLine(" %s %s", ui.Green("✔ "+pkg), ui.Cyan("("+duration+")"))
installed = append(installed, pkg)
} else {
progress.PrintLine(" %s %s", ui.Red("✗ "+pkg+" ("+errMsg+")"), ui.Cyan("("+duration+")"))
failed = append(failed, failedJob{
installJob: installJob{name: pkg, isCask: true},
errMsg: errMsg,
Expand All @@ -221,21 +232,39 @@ func retryFailedJobs(ctx context.Context, allFailed []failedJob, installedFormul
ui.Printf("\nRetrying %d failed packages...\n", len(allFailed))

for _, f := range allFailed {
phase := progresspkg.PhaseHomebrew
if f.isCask {
phase = progresspkg.PhaseApplications
}
// Streaming: the retry outcome supersedes the earlier StepFail in the
// log; without these events the wizard would show ✗ for a package
// that actually installed on retry.
if streaming() {
progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepStart, Command: "retrying " + f.name})
}
var errMsg string
if f.isCask {
errMsg = installSmartCaskWithError(ctx, f.name)
} else {
errMsg = installFormulaWithError(ctx, f.name)
}
if errMsg == "" {
ui.Printf(" ✔ %s (retry succeeded)\n", f.name)
if streaming() {
progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepOK, Detail: "retry succeeded"})
} else {
ui.Printf(" ✔ %s (retry succeeded)\n", f.name)
}
if f.isCask {
*installedCasks = append(*installedCasks, f.name)
} else {
*installedFormulae = append(*installedFormulae, aliasMap[f.name])
}
} else {
ui.Printf(" ✗ %s (still failed)\n", f.name)
if streaming() {
progressSink.Emit(progresspkg.Event{Phase: phase, Name: f.name, Status: progresspkg.StepFail, Detail: "still failed: " + errMsg})
} else {
ui.Printf(" ✗ %s (still failed)\n", f.name)
}
}
}

Expand Down Expand Up @@ -278,28 +307,28 @@ func handleFailedJobs(failed []failedJob) {
}
}

func runSerialInstallWithProgress(ctx context.Context, pkgs []string, progress *ui.StickyProgress) []failedJob {
// runSerialInstallWithProgress installs formulae one by one. bar is nil when a
// streaming progress sink is registered.
func runSerialInstallWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) []failedJob {
if len(pkgs) == 0 {
return nil
}

failed := make([]failedJob, 0)
for _, pkg := range pkgs {
job := installJob{name: pkg, isCask: false}
progress.SetCurrent(job.name)
stepStart(bar, progresspkg.PhaseHomebrew, job.name, "brew install "+job.name)

start := time.Now()
errMsg := installFormulaWithError(ctx, job.name)
elapsed := time.Since(start)

progress.IncrementWithStatus(errMsg == "")
duration := ui.FormatDuration(elapsed)
stepDone(bar, progresspkg.PhaseHomebrew, job.name, errMsg == "", errMsg, duration)
if errMsg == "" {
progress.PrintLine(" %s %s", ui.Green("✔ "+job.name), ui.Cyan("("+duration+")"))
continue
}

progress.PrintLine(" %s %s", ui.Red("✗ "+job.name+" ("+errMsg+")"), ui.Cyan("("+duration+")"))
failed = append(failed, failedJob{
installJob: job,
errMsg: errMsg,
Expand Down Expand Up @@ -331,12 +360,19 @@ func brewInstallCmd(ctx context.Context, args ...string) *exec.Cmd {

// brewCombinedOutputWithTTY runs a brew command capturing combined output while
// providing a TTY for stdin so that sudo password prompts work.
//
// In streaming mode the TUI owns the terminal in raw mode: a sudo prompt would
// be invisible and its keystrokes swallowed by the TUI's input reader, hanging
// the install. Withholding the TTY makes sudo fail fast instead, surfacing a
// visible step failure.
func brewCombinedOutputWithTTY(ctx context.Context, args ...string) (string, error) {
cmd := brewInstallCmd(ctx, args...)
tty, opened := system.OpenTTY()
if opened {
cmd.Stdin = tty
defer tty.Close() //nolint:errcheck // best-effort TTY cleanup
if !streaming() {
tty, opened := system.OpenTTY()
if opened {
cmd.Stdin = tty
defer tty.Close() //nolint:errcheck // best-effort TTY cleanup
}
}
output, err := cmd.CombinedOutput()
return string(output), err
Expand Down
69 changes: 69 additions & 0 deletions internal/brew/streaming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package brew

import (
"github.com/openbootdotdev/openboot/internal/progress"
"github.com/openbootdotdev/openboot/internal/ui"
)

// progressSink, when non-nil, makes InstallWithProgress stream structured
// progress.Events instead of drawing a ui.StickyProgress bar. The install TUI
// registers a sink so it can own the terminal; the default console flow leaves
// it nil. Mirrors the SetRunner swap-and-restore pattern.
var progressSink progress.Sink

// SetProgressSink registers a streaming progress sink and returns a restore
// func that clears it.
func SetProgressSink(s progress.Sink) (restore func()) {
prev := progressSink
progressSink = s
return func() { progressSink = prev }
}

// streaming reports whether a sink is registered (TUI mode).
func streaming() bool { return progressSink != nil }

// EmitSkipped emits an already-installed StepOK event for each named package,
// upholding the streaming invariant that every planned package produces
// exactly one terminal event. Callers that filter packages before reaching
// the install loops (state-file skips, alias-resolved skips) use this so the
// renderer's totals still reconcile. No-op when no sink is registered.
func EmitSkipped(formulae, casks []string) {
if !streaming() {
return
}
for _, n := range formulae {
progressSink.Emit(progress.Event{Phase: progress.PhaseHomebrew, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail})
}
for _, n := range casks {
progressSink.Emit(progress.Event{Phase: progress.PhaseApplications, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail})
}
}

// stepStart reports the beginning of a package install: emits a StepStart event
// when streaming, otherwise advances the sticky progress bar.
func stepStart(bar *ui.StickyProgress, phase, name, command string) {
if streaming() {
progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepStart, Command: command})
return
}
bar.SetCurrent(name)
}

// stepDone reports the result of a package install, preserving the exact
// console output when not streaming.
func stepDone(bar *ui.StickyProgress, phase, name string, ok bool, errMsg, duration string) {
if streaming() {
if ok {
progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepOK, Detail: duration})
} else {
progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepFail, Detail: errMsg})
}
return
}
bar.IncrementWithStatus(ok)
if ok {
bar.PrintLine(" %s %s", ui.Green("✔ "+name), ui.Cyan("("+duration+")"))
} else {
bar.PrintLine(" %s %s", ui.Red("✗ "+name+" ("+errMsg+")"), ui.Cyan("("+duration+")"))
}
}
43 changes: 43 additions & 0 deletions internal/brew/streaming_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package brew

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openbootdotdev/openboot/internal/progress"
)

func TestSetProgressSinkSwapAndRestore(t *testing.T) {
require.Nil(t, progressSink)
require.False(t, streaming())

var got []progress.Event
restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) })
require.True(t, streaming())

progressSink.Emit(progress.Event{Name: "x"})
restore()
assert.Nil(t, progressSink)
assert.False(t, streaming())
assert.Len(t, got, 1)
}

// When streaming, the step helpers must emit events and never touch the (nil) bar.
func TestStepHelpersEmitWhenStreaming(t *testing.T) {
var got []progress.Event
restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) })
defer restore()

assert.NotPanics(t, func() {
stepStart(nil, progress.PhaseHomebrew, "git", "brew install git")
stepDone(nil, progress.PhaseHomebrew, "git", true, "", "1.2s")
stepDone(nil, progress.PhaseApplications, "figma", false, "download failed", "0.5s")
})

require.Len(t, got, 3)
assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepStart, Command: "brew install git"}, got[0])
assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepOK, Detail: "1.2s"}, got[1])
assert.Equal(t, progress.Event{Phase: progress.PhaseApplications, Name: "figma", Status: progress.StepFail, Detail: "download failed"}, got[2])
}
Loading
Loading