diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index eebede0..12230b0 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -2,7 +2,7 @@ # Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... internal/auth/login.go:195 -internal/brew/brew_install.go:356 +internal/brew/brew_install.go:324 internal/cli/snapshot.go:22 internal/diff/compare.go:247 internal/diff/compare.go:253 @@ -12,7 +12,7 @@ internal/dotfiles/dotfiles.go:79 internal/dotfiles/dotfiles.go:376 internal/dotfiles/dotfiles.go:474 internal/installer/step_system.go:126 -internal/npm/npm.go:23 +internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21 internal/shell/shell.go:184 internal/updater/updater.go:205 diff --git a/internal/brew/brew_install.go b/internal/brew/brew_install.go index 29a3aa5..5916ca7 100644 --- a/internal/brew/brew_install.go +++ b/internal/brew/brew_install.go @@ -9,7 +9,6 @@ import ( "strings" "time" - progresspkg "github.com/openbootdotdev/openboot/internal/progress" "github.com/openbootdotdev/openboot/internal/system" "github.com/openbootdotdev/openboot/internal/ui" ) @@ -110,27 +109,23 @@ 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, skippedCli []string + var newCli []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, skippedCask []string + var newCask []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 { @@ -147,14 +142,9 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun return installedFormulae, installedCasks, preErr } - // 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() - } + bar := ui.NewStickyProgress(len(newCli) + len(newCask)) + bar.SetSkipped(skipped) + bar.Start() var allFailed []failedJob @@ -178,9 +168,7 @@ func InstallWithProgress(ctx context.Context, cliPkgs, caskPkgs []string, dryRun allFailed = append(allFailed, caskFailed...) } - if bar != nil { - bar.Finish() - } + bar.Finish() allFailed = retryFailedJobs(ctx, allFailed, &installedFormulae, &installedCasks, aliasMap) @@ -198,18 +186,17 @@ 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. bar is nil when a -// streaming progress sink is registered. +// suppressed. Returns successful installs and failed jobs. func installCasksWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) (installed []string, failed []failedJob) { for _, pkg := range pkgs { - stepStart(bar, progresspkg.PhaseApplications, pkg, "brew install --cask "+pkg) + stepStart(bar, pkg) start := time.Now() errMsg := installCaskWithProgress(ctx, pkg) elapsed := time.Since(start) duration := ui.FormatDuration(elapsed) - stepDone(bar, progresspkg.PhaseApplications, pkg, errMsg == "", errMsg, duration) + stepDone(bar, pkg, errMsg == "", errMsg, duration) if errMsg == "" { installed = append(installed, pkg) } else { @@ -232,16 +219,6 @@ 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) @@ -249,22 +226,14 @@ func retryFailedJobs(ctx context.Context, allFailed []failedJob, installedFormul errMsg = installFormulaWithError(ctx, f.name) } if errMsg == "" { - 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) - } + ui.Printf(" ✔ %s (retry succeeded)\n", f.name) if f.isCask { *installedCasks = append(*installedCasks, f.name) } else { *installedFormulae = append(*installedFormulae, aliasMap[f.name]) } } else { - 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) - } + ui.Printf(" ✗ %s (still failed)\n", f.name) } } @@ -307,8 +276,7 @@ func handleFailedJobs(failed []failedJob) { } } -// runSerialInstallWithProgress installs formulae one by one. bar is nil when a -// streaming progress sink is registered. +// runSerialInstallWithProgress installs formulae one by one. func runSerialInstallWithProgress(ctx context.Context, pkgs []string, bar *ui.StickyProgress) []failedJob { if len(pkgs) == 0 { return nil @@ -317,14 +285,14 @@ func runSerialInstallWithProgress(ctx context.Context, pkgs []string, bar *ui.St failed := make([]failedJob, 0) for _, pkg := range pkgs { job := installJob{name: pkg, isCask: false} - stepStart(bar, progresspkg.PhaseHomebrew, job.name, "brew install "+job.name) + stepStart(bar, job.name) start := time.Now() errMsg := installFormulaWithError(ctx, job.name) elapsed := time.Since(start) duration := ui.FormatDuration(elapsed) - stepDone(bar, progresspkg.PhaseHomebrew, job.name, errMsg == "", errMsg, duration) + stepDone(bar, job.name, errMsg == "", errMsg, duration) if errMsg == "" { continue } @@ -347,9 +315,9 @@ func installCaskWithProgress(ctx context.Context, pkg string) string { } // Runner-exempt: this helper sets HOMEBREW_NO_AUTO_UPDATE=1 and returns a raw -// *exec.Cmd so callers can wire custom stdout pipes (StickyProgress streaming) -// and TTY stdin (sudo prompts for cask installs). The Runner interface cannot -// express either of those cleanly, so Install / InstallCask / +// *exec.Cmd so callers can attach or capture output and wire TTY stdin (sudo +// prompts for cask installs). The Runner interface cannot express those needs +// cleanly, so Install / InstallCask / // installCaskWithProgress / brewCombinedOutputWithTTY / installFormulaWithError // / installSmartCaskWithError continue to use this helper directly. func brewInstallCmd(ctx context.Context, args ...string) *exec.Cmd { @@ -360,19 +328,12 @@ 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...) - if !streaming() { - tty, opened := system.OpenTTY() - if opened { - cmd.Stdin = tty - defer tty.Close() //nolint:errcheck // best-effort TTY cleanup - } + tty, opened := system.OpenTTY() + if opened { + cmd.Stdin = tty + defer tty.Close() //nolint:errcheck // best-effort TTY cleanup } output, err := cmd.CombinedOutput() return string(output), err diff --git a/internal/brew/install_progress.go b/internal/brew/install_progress.go new file mode 100644 index 0000000..f6fae61 --- /dev/null +++ b/internal/brew/install_progress.go @@ -0,0 +1,16 @@ +package brew + +import "github.com/openbootdotdev/openboot/internal/ui" + +func stepStart(bar *ui.StickyProgress, name string) { + bar.SetCurrent(name) +} + +func stepDone(bar *ui.StickyProgress, name string, ok bool, errMsg, duration string) { + bar.IncrementWithStatus(ok) + if ok { + bar.PrintLine(" %s %s", ui.Green("✔ "+name), ui.Cyan("("+duration+")")) + } else { + bar.PrintLine(" %s %s", ui.Red("✗ "+name+" ("+errMsg+")"), ui.Cyan("("+duration+")")) + } +} diff --git a/internal/brew/runner.go b/internal/brew/runner.go index 973056a..4af46f5 100644 --- a/internal/brew/runner.go +++ b/internal/brew/runner.go @@ -14,11 +14,11 @@ import ( // // Coverage notes — the following call sites remain outside the Runner because // they need features Runner does not express cleanly: -// - progress-stream install path (brew_install.go: brewInstallCmd / Install / +// - progress-aware install path (brew_install.go: brewInstallCmd / Install / // InstallCask / installCaskWithProgress / brewCombinedOutputWithTTY / // installFormulaWithError / installSmartCaskWithError) — these rely on -// the HOMEBREW_NO_AUTO_UPDATE env var plus custom stdout pipe wiring for -// StickyProgress and TTY stdin for sudo prompts. +// the HOMEBREW_NO_AUTO_UPDATE env var plus custom output capture and TTY +// stdin for sudo prompts. type Runner interface { // Output runs `brew args...` and returns stdout only. Output(args ...string) ([]byte, error) diff --git a/internal/brew/streaming.go b/internal/brew/streaming.go deleted file mode 100644 index 369048d..0000000 --- a/internal/brew/streaming.go +++ /dev/null @@ -1,69 +0,0 @@ -package brew - -import ( - "github.com/openbootdotdev/openboot/internal/progress" - "github.com/openbootdotdev/openboot/internal/ui" -) - -// progressSink, when non-nil, makes InstallWithProgress stream structured -// progress.Events instead of drawing a ui.StickyProgress bar. The install TUI -// registers a sink so it can own the terminal; the default console flow leaves -// it nil. Mirrors the SetRunner swap-and-restore pattern. -var progressSink progress.Sink - -// SetProgressSink registers a streaming progress sink and returns a restore -// func that clears it. -func SetProgressSink(s progress.Sink) (restore func()) { - prev := progressSink - progressSink = s - return func() { progressSink = prev } -} - -// streaming reports whether a sink is registered (TUI mode). -func streaming() bool { return progressSink != nil } - -// EmitSkipped emits an already-installed StepOK event for each named package, -// upholding the streaming invariant that every planned package produces -// exactly one terminal event. Callers that filter packages before reaching -// the install loops (state-file skips, alias-resolved skips) use this so the -// renderer's totals still reconcile. No-op when no sink is registered. -func EmitSkipped(formulae, casks []string) { - if !streaming() { - return - } - for _, n := range formulae { - progressSink.Emit(progress.Event{Phase: progress.PhaseHomebrew, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) - } - for _, n := range casks { - progressSink.Emit(progress.Event{Phase: progress.PhaseApplications, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) - } -} - -// stepStart reports the beginning of a package install: emits a StepStart event -// when streaming, otherwise advances the sticky progress bar. -func stepStart(bar *ui.StickyProgress, phase, name, command string) { - if streaming() { - progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepStart, Command: command}) - return - } - bar.SetCurrent(name) -} - -// stepDone reports the result of a package install, preserving the exact -// console output when not streaming. -func stepDone(bar *ui.StickyProgress, phase, name string, ok bool, errMsg, duration string) { - if streaming() { - if ok { - progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepOK, Detail: duration}) - } else { - progressSink.Emit(progress.Event{Phase: phase, Name: name, Status: progress.StepFail, Detail: errMsg}) - } - return - } - bar.IncrementWithStatus(ok) - if ok { - bar.PrintLine(" %s %s", ui.Green("✔ "+name), ui.Cyan("("+duration+")")) - } else { - bar.PrintLine(" %s %s", ui.Red("✗ "+name+" ("+errMsg+")"), ui.Cyan("("+duration+")")) - } -} diff --git a/internal/brew/streaming_test.go b/internal/brew/streaming_test.go deleted file mode 100644 index 47b8fd3..0000000 --- a/internal/brew/streaming_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package brew - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openbootdotdev/openboot/internal/progress" -) - -func TestSetProgressSinkSwapAndRestore(t *testing.T) { - require.Nil(t, progressSink) - require.False(t, streaming()) - - var got []progress.Event - restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) - require.True(t, streaming()) - - progressSink.Emit(progress.Event{Name: "x"}) - restore() - assert.Nil(t, progressSink) - assert.False(t, streaming()) - assert.Len(t, got, 1) -} - -// When streaming, the step helpers must emit events and never touch the (nil) bar. -func TestStepHelpersEmitWhenStreaming(t *testing.T) { - var got []progress.Event - restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) - defer restore() - - assert.NotPanics(t, func() { - stepStart(nil, progress.PhaseHomebrew, "git", "brew install git") - stepDone(nil, progress.PhaseHomebrew, "git", true, "", "1.2s") - stepDone(nil, progress.PhaseApplications, "figma", false, "download failed", "0.5s") - }) - - require.Len(t, got, 3) - assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepStart, Command: "brew install git"}, got[0]) - assert.Equal(t, progress.Event{Phase: progress.PhaseHomebrew, Name: "git", Status: progress.StepOK, Detail: "1.2s"}, got[1]) - assert.Equal(t, progress.Event{Phase: progress.PhaseApplications, Name: "figma", Status: progress.StepFail, Detail: "download failed"}, got[2]) -} diff --git a/internal/installer/step_packages.go b/internal/installer/step_packages.go index 2a6d2bf..02a1ec0 100644 --- a/internal/installer/step_packages.go +++ b/internal/installer/step_packages.go @@ -116,25 +116,22 @@ func applyPackages(ctx context.Context, plan InstallPlan, r Reporter) error { // } } - var stateSkippedCli, stateSkippedCask []string + stateSkipped := 0 for _, pkg := range cliPkgs { if !state.isFormulaInstalled(pkg) { newCli = append(newCli, pkg) } else { - stateSkippedCli = append(stateSkippedCli, pkg) + stateSkipped++ } } for _, pkg := range caskPkgs { if !state.isCaskInstalled(pkg) { newCask = append(newCask, pkg) } else { - stateSkippedCask = append(stateSkippedCask, pkg) + stateSkipped++ } } - // Streaming invariant: state-file skips still produce terminal events. - brew.EmitSkipped(stateSkippedCli, stateSkippedCask) - stateSkipped := len(stateSkippedCli) + len(stateSkippedCask) if stateSkipped > 0 { r.Muted(fmt.Sprintf("Skipping %d packages from previous install", stateSkipped)) } @@ -211,18 +208,15 @@ func applyNpm(ctx context.Context, plan InstallPlan, r Reporter) error { //nolin } } - var stateSkippedNpm []string + stateSkipped := 0 for _, pkg := range npmPkgs { if !state.isNpmInstalled(pkg) { newNpm = append(newNpm, pkg) } else { - stateSkippedNpm = append(stateSkippedNpm, pkg) + stateSkipped++ } } - // Streaming invariant: state-file skips still produce terminal events. - npm.EmitSkipped(stateSkippedNpm) - stateSkipped := len(stateSkippedNpm) if stateSkipped > 0 { r.Muted(fmt.Sprintf("Skipping %d npm packages from previous install", stateSkipped)) } diff --git a/internal/npm/install_progress.go b/internal/npm/install_progress.go new file mode 100644 index 0000000..2239363 --- /dev/null +++ b/internal/npm/install_progress.go @@ -0,0 +1,17 @@ +package npm + +import "github.com/openbootdotdev/openboot/internal/ui" + +func npmStepStart(bar *ui.StickyProgress, name string) { + bar.SetCurrent(name) +} + +func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg string) { + // Print then Increment, matching the original console ordering exactly. + if ok { + bar.PrintLine(" ✔ %s", name) + } else { + bar.PrintLine(" ✗ %s (%s)", name, errMsg) + } + bar.Increment() +} diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 3fd4ad0..8b2ba95 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/openbootdotdev/openboot/internal/progress" "github.com/openbootdotdev/openboot/internal/ui" ) @@ -109,16 +108,12 @@ func InstallContext(ctx context.Context, packages []string, dryRun bool) error { return fmt.Errorf("list installed packages: %w", err) } - var toInstall, alreadyInstalled []string + var toInstall []string for _, p := range packages { if !installed[p] { toInstall = append(toInstall, p) - } else { - alreadyInstalled = append(alreadyInstalled, p) } } - // Streaming invariant: skipped packages still produce a terminal event. - EmitSkipped(alreadyInstalled) skipped := len(packages) - len(toInstall) if skipped > 0 { @@ -183,29 +178,17 @@ func warnIfNodeVersionTooLow(packages []string) { // fails it falls back to sequential per-package installs. Returns the list of // package names that could not be installed and any fatal error. func installBatchContext(ctx context.Context, toInstall []string) (failed []string, err error) { - if streaming() { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Status: progress.StepStart, Command: "npm install -g " + strings.Join(toInstall, " ")}) - } - args := append([]string{"install", "-g"}, toInstall...) batchOutput, batchErr := runnerCombinedOutputContext(ctx, args...) if batchErr == nil { - if streaming() { - for _, p := range toInstall { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: p, Status: progress.StepOK}) - } - } else { - ui.Success(fmt.Sprintf(" ✔ %d npm packages installed", len(toInstall))) - } + ui.Success(fmt.Sprintf(" ✔ %d npm packages installed", len(toInstall))) return nil, nil } batchError := parseNpmError(string(batchOutput)) - if !streaming() { - ui.Warn(fmt.Sprintf("Batch install failed (%s), falling back to sequential...", batchError)) - ui.Println() - } + ui.Warn(fmt.Sprintf("Batch install failed (%s), falling back to sequential...", batchError)) + ui.Println() return installSequentialContext(ctx, toInstall) } @@ -218,49 +201,31 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [ return nil, fmt.Errorf("list packages after batch: %w", err) } - var remaining, batchRecovered []string + var remaining []string for _, pkg := range toInstall { if !nowInstalled[pkg] { remaining = append(remaining, pkg) - } else { - batchRecovered = append(batchRecovered, pkg) - } - } - // Packages the failed batch did manage to install must still produce - // their terminal event, or a streaming renderer's totals never complete. - if streaming() { - for _, pkg := range batchRecovered { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: pkg, Status: progress.StepOK}) } } if len(remaining) == 0 { - if !streaming() { - ui.Success("All npm packages already installed after partial batch!") - } + ui.Success("All npm packages already installed after partial batch!") return nil, nil } - // bar stays nil when a streaming sink is registered. - var bar *ui.StickyProgress - if !streaming() { - bar = ui.NewStickyProgress(len(remaining)) - bar.Start() - } + bar := ui.NewStickyProgress(len(remaining)) + bar.Start() for _, pkg := range remaining { npmStepStart(bar, pkg) - start := time.Now() errMsg := installNpmPackageWithRetryContext(ctx, pkg) - npmStepDone(bar, pkg, errMsg == "", errMsg, ui.FormatDuration(time.Since(start))) + npmStepDone(bar, pkg, errMsg == "", errMsg) if errMsg != "" { failed = append(failed, pkg) } } - if bar != nil { - bar.Finish() - } + bar.Finish() return failed, nil } diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go deleted file mode 100644 index 3ac9fa0..0000000 --- a/internal/npm/streaming.go +++ /dev/null @@ -1,66 +0,0 @@ -package npm - -import ( - "github.com/openbootdotdev/openboot/internal/progress" - "github.com/openbootdotdev/openboot/internal/ui" -) - -// progressSink, when non-nil, makes the npm installer stream structured -// progress.Events instead of drawing a ui.StickyProgress bar. Mirrors brew's -// SetProgressSink swap-and-restore pattern. -var progressSink progress.Sink - -// SetProgressSink registers a streaming progress sink and returns a restore -// func that clears it. -func SetProgressSink(s progress.Sink) (restore func()) { - prev := progressSink - progressSink = s - return func() { progressSink = prev } -} - -// streaming reports whether a sink is registered (TUI mode). -func streaming() bool { return progressSink != nil } - -// EmitSkipped emits an already-installed StepOK event for each named package, -// upholding the streaming invariant that every planned package produces -// exactly one terminal event. No-op when no sink is registered. -func EmitSkipped(names []string) { - if !streaming() { - return - } - for _, n := range names { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: n, Status: progress.StepOK, Detail: progress.SkipDetail}) - } -} - -// npmStepStart reports the start of a single npm package install. -func npmStepStart(bar *ui.StickyProgress, name string) { - if streaming() { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepStart, Command: "npm install -g " + name}) - return - } - bar.SetCurrent(name) -} - -// npmStepDone reports the result of a single npm package install, preserving -// the exact console output when not streaming. 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, Detail: duration}) - } else { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepFail, Detail: errMsg}) - } - return - } - // Print then Increment, matching the original console ordering exactly. - if ok { - bar.PrintLine(" ✔ %s", name) - } else { - bar.PrintLine(" ✗ %s (%s)", name, errMsg) - } - bar.Increment() -} diff --git a/internal/npm/streaming_test.go b/internal/npm/streaming_test.go deleted file mode 100644 index 0d2e803..0000000 --- a/internal/npm/streaming_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package npm - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openbootdotdev/openboot/internal/progress" -) - -func TestSetProgressSinkSwapAndRestore(t *testing.T) { - require.Nil(t, progressSink) - require.False(t, streaming()) - - restore := SetProgressSink(func(progress.Event) {}) - require.True(t, streaming()) - restore() - assert.False(t, streaming()) -} - -func TestNpmStepHelpersEmitWhenStreaming(t *testing.T) { - var got []progress.Event - restore := SetProgressSink(func(ev progress.Event) { got = append(got, ev) }) - defer restore() - - assert.NotPanics(t, func() { - npmStepStart(nil, "typescript") - npmStepDone(nil, "typescript", true, "", "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, 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/progress/progress.go b/internal/progress/progress.go deleted file mode 100644 index 9b93a1f..0000000 --- a/internal/progress/progress.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package progress defines the streaming progress event contract between the -// install engine (brew, npm) and a live renderer such as the install TUI. -// -// It is a leaf package with no internal dependencies so brew, npm, installer, -// and the TUI can all import it without creating an import cycle. -// -// When no Sink is registered the install engine renders its own progress -// (ui.StickyProgress). When a Sink is registered the engine emits Events -// instead and draws nothing to stdout, letting the caller own the display. -package progress - -// Status describes where a step is in its lifecycle. -type Status int - -const ( - // StepStart is emitted when a step begins (before the command runs). - StepStart Status = iota - // StepOK is emitted when a step finishes successfully. - StepOK - // StepFail is emitted when a step fails. - StepFail -) - -// Event is a single progress signal emitted during installation. -type Event struct { - Phase string // pipeline phase, e.g. "Homebrew", "Applications", "npm globals" - Name string // step name, e.g. the package being installed - Status Status - Command string // shell command being run, for the live log (StepStart only) - Detail string // result detail: version/duration on success, error message on failure -} - -// Canonical phase names emitted by the install engine. The TUI matches on -// these so brew, npm, and the renderer stay in agreement. -const ( - PhaseHomebrew = "Homebrew" - PhaseApplications = "Applications" - PhaseNpm = "npm globals" -) - -// SkipDetail marks a StepOK event for a package that needed no work because it -// was already installed. The invariant the engine upholds in streaming mode: -// every planned package produces exactly one terminal event — StepOK (installed -// or SkipDetail) or StepFail — so a renderer's totals always reconcile. -const SkipDetail = "already installed" - -// Sink receives install progress events. A nil Sink means "no streaming -// renderer registered" — the engine falls back to its own progress output. -type Sink func(Event) - -// Emit sends an event to s, tolerating a nil sink. -func (s Sink) Emit(ev Event) { - if s != nil { - s(ev) - } -} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go deleted file mode 100644 index 86788ce..0000000 --- a/internal/progress/progress_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package progress - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSinkEmitNilSafe(t *testing.T) { - var s Sink // nil - assert.NotPanics(t, func() { s.Emit(Event{Name: "x"}) }) -} - -func TestSinkEmitForwards(t *testing.T) { - var got []Event - s := Sink(func(e Event) { got = append(got, e) }) - s.Emit(Event{Phase: PhaseHomebrew, Name: "git", Status: StepOK, Detail: "1s"}) - assert.Equal(t, []Event{{Phase: PhaseHomebrew, Name: "git", Status: StepOK, Detail: "1s"}}, got) -}