Skip to content
Open
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
31 changes: 27 additions & 4 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,22 +194,45 @@ func runPipelineInstall(ctx context.Context) error {
// re-run afterwards via ShowScreenRecordingReminderAfterTUI.
plan.Silent = true

runErr := wizard.RunPipeline(installCfg.Version, wizard.PhasesForPlan(plan),
// Post-install needs a script preview + confirm, which the alt-screen can't
// host. Strip it from the streamed plan and run it after teardown on a normal
// terminal (Silent=true would otherwise gate it out entirely — the R1 bug).
streamed, deferredPostInstall := splitPostInstall(plan)

runErr := wizard.RunPipeline(installCfg.Version, wizard.PhasesForPlan(streamed),
func(ctx context.Context, r installer.Reporter) error {
return installer.ApplyContext(ctx, plan, r)
return installer.ApplyContext(ctx, streamed, r)
})
if errors.Is(runErr, wizard.ErrAborted) || errors.Is(runErr, context.Canceled) {
return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs")
}
// The install ran (cleanly or with soft errors): show the reminder on the
// restored terminal, then persist the sync source on a clean run.
// The install ran (cleanly or with soft errors). On a clean run, run the
// deferred post-install script (Step 8, matching the linear order) before the
// reminder; a failing script is a soft error, not fatal. Then show the
// reminder and persist the sync source.
if runErr == nil && len(deferredPostInstall) > 0 {
plan.PostInstall = deferredPostInstall
if piErr := installer.RunPostInstallAfterTUI(plan); piErr != nil {
ui.Error(fmt.Sprintf("Post-install script failed: %v", piErr))
}
}
installer.ShowScreenRecordingReminderAfterTUI(plan)
if runErr == nil {
saveSyncSourceIfRemote(installCfg)
}
return runErr
}

// splitPostInstall separates a plan into the version streamed through the wizard
// pipeline (post-install removed — the alt-screen can't host its confirm) and
// the post-install script to run after teardown. Keeping it a pure function
// makes the split testable without a TTY.
func splitPostInstall(plan installer.InstallPlan) (streamed installer.InstallPlan, postInstall []string) {
postInstall = plan.PostInstall
plan.PostInstall = nil
return plan, postInstall
}

// runInstallWizard launches the full-screen install TUI and runs the resulting
// install. The wizard owns the whole interactive flow (planning + apply); back
// on the normal terminal, follow-ups that can't run inside the alt-screen
Expand Down
34 changes: 34 additions & 0 deletions internal/cli/post_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cli

import (
"testing"

"github.com/stretchr/testify/assert"

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

// splitPostInstall must remove the post-install script from the plan streamed
// through the wizard pipeline (the alt-screen can't host its confirm) while
// preserving it for the after-teardown run. This is the R1 regression: the
// pipeline path forced plan.Silent=true, which silently gated out post-install
// execution that the linear path used to preview, confirm, and run.
func TestSplitPostInstall(t *testing.T) {
plan := installer.InstallPlan{
Formulae: []string{"jq"},
PostInstall: []string{"echo hi", "echo bye"},
}

streamed, deferred := splitPostInstall(plan)

assert.Nil(t, streamed.PostInstall, "streamed plan must not carry post-install")
assert.Equal(t, []string{"echo hi", "echo bye"}, deferred, "post-install deferred to after teardown")
assert.Equal(t, []string{"jq"}, streamed.Formulae, "the rest of the plan is untouched")
assert.Equal(t, []string{"echo hi", "echo bye"}, plan.PostInstall, "the caller's plan is not mutated")
}

func TestSplitPostInstall_Empty(t *testing.T) {
streamed, deferred := splitPostInstall(installer.InstallPlan{Formulae: []string{"jq"}})
assert.Empty(t, deferred)
assert.Equal(t, []string{"jq"}, streamed.Formulae)
}
54 changes: 54 additions & 0 deletions internal/installer/applycontext_abort_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package installer

import (
"context"
"strings"
"testing"

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

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

// recordReporter captures Header calls so a test can assert which install
// phases ApplyContext actually entered.
type recordReporter struct{ headers []string }

func (r *recordReporter) Header(msg string) { r.headers = append(r.headers, msg) }
func (r *recordReporter) Info(string) {}
func (r *recordReporter) Success(string) {}
func (r *recordReporter) Warn(string) {}
func (r *recordReporter) Error(string) {}
func (r *recordReporter) Muted(string) {}

// A cancelled context must stop ApplyContext before the config steps run, so a
// ctrl+c abort doesn't keep symlinking dotfiles / rewriting macOS defaults after
// the user asked to stop. (M3: the config steps take no ctx, so without the
// ctx.Err() gates they would run to completion regardless of cancellation.)
func TestApplyContextAbortsBeforeConfigStepsOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancelled — simulates ctrl+c landing during the package phase

plan := InstallPlan{
SkipGit: true, // no git, no packages → reach the first ctx gate immediately
InstallOhMyZsh: true, // would emit "Shell Configuration" if the step ran
DotfilesURL: "https://github.com/example/dotfiles",
MacOSPrefs: []macos.Preference{
{Domain: "com.apple.dock", Key: "tilesize", Type: "int", Value: "48"},
},
}

rr := &recordReporter{}
err := ApplyContext(ctx, plan, rr)

require.ErrorIs(t, err, context.Canceled, "an aborted apply reports the cancellation")
for _, h := range rr.headers {
assert.NotContains(t, h, "Shell Config", "aborted install must not run the shell step")
assert.NotContains(t, h, "Dotfiles", "aborted install must not run dotfiles")
assert.NotContains(t, h, "macOS Prefer", "aborted install must not run macOS prefs")
}
// It's fine to have entered the package header before the gate fired; the
// point is that no *config* step started.
assert.NotContains(t, strings.Join(rr.headers, "|"), "Post-Install")
}
75 changes: 61 additions & 14 deletions internal/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ func runInstallContext(ctx context.Context, opts *config.InstallOptions, st *con
func PlanForConfig(cfg *config.Config) (InstallPlan, error) {
opts := cfg.ToInstallOptions()
st := cfg.ToInstallState()
// Mirror runInstallContext's install_started event: the pipeline path skips
// runInstallContext, so without this a streamed install logs install_completed
// (from ApplyContext) with no matching install_started.
slog.Info("install_started",
"version", opts.Version,
"preset", opts.Preset,
"user", opts.User,
"dry_run", opts.DryRun,
"silent", opts.Silent,
)
if err := checkDependencies(opts, st); err != nil {
return InstallPlan{}, fmt.Errorf("check dependencies: %w", err)
}
Expand Down Expand Up @@ -120,27 +130,37 @@ func ApplyContext(ctx context.Context, plan InstallPlan, r Reporter) error {
softErrs = append(softErrs, fmt.Errorf("brew: %w", err))
}

// ctrl+c cancels ctx. brew/npm honour it (exec.CommandContext), but the
// config steps below take no ctx, so without these gates an aborted install
// would keep symlinking dotfiles and rewriting macOS defaults after the user
// asked to stop. Bail between phases so an abort skips not-yet-started work.
if err := ctx.Err(); err != nil {
return abortWith(softErrs, err)
}

if err := applyNpm(ctx, plan, r); err != nil {
r.Error(fmt.Sprintf("npm package installation failed: %v", err))
softErrs = append(softErrs, fmt.Errorf("npm: %w", err))
}

if !plan.PackagesOnly {
if err := applyShell(plan, r); err != nil {
r.Error(fmt.Sprintf("Shell setup failed: %v", err))
softErrs = append(softErrs, fmt.Errorf("shell: %w", err))
}
if err := applyDotfiles(plan, r); err != nil {
r.Error(fmt.Sprintf("Dotfiles setup failed: %v", err))
softErrs = append(softErrs, fmt.Errorf("dotfiles: %w", err))
configSteps := []struct {
name, failMsg string
apply func(InstallPlan, Reporter) error
}{
{"shell", "Shell setup failed", applyShell},
{"dotfiles", "Dotfiles setup failed", applyDotfiles},
{"macos", "macOS configuration failed", applyMacOSPrefs},
{"post-install", "Post-install script failed", applyPostInstall},
}
if err := applyMacOSPrefs(plan, r); err != nil {
r.Error(fmt.Sprintf("macOS configuration failed: %v", err))
softErrs = append(softErrs, fmt.Errorf("macos: %w", err))
}
if err := applyPostInstall(plan, r); err != nil {
r.Error(fmt.Sprintf("Post-install script failed: %v", err))
softErrs = append(softErrs, fmt.Errorf("post-install: %w", err))
for _, s := range configSteps {
if err := ctx.Err(); err != nil {
return abortWith(softErrs, err)
}
if err := s.apply(plan, r); err != nil {
r.Error(fmt.Sprintf("%s: %v", s.failMsg, err))
softErrs = append(softErrs, fmt.Errorf("%s: %w", s.name, err))
}
}
}

Expand All @@ -154,6 +174,14 @@ func ApplyContext(ctx context.Context, plan InstallPlan, r Reporter) error {
return nil
}

// abortWith joins the soft errors accumulated so far with the context
// cancellation cause, for when ApplyContext bails out early on a cancelled
// context (ctrl+c). Returning here stops the remaining steps so an aborted
// install doesn't keep mutating the system.
func abortWith(softErrs []error, cause error) error {
return errors.Join(append(softErrs, cause)...)
}

func showCompletionFromPlan(plan InstallPlan, r Reporter, errCount int) {
ui.Println()
if errCount > 0 {
Expand Down Expand Up @@ -198,6 +226,25 @@ func ShowScreenRecordingReminderAfterTUI(plan InstallPlan) {
showScreenRecordingReminderFromPlan(plan)
}

// RunPostInstallAfterTUI runs the plan's post-install script on a normal
// terminal after the full-screen wizard has torn down. The wizard forces
// plan.Silent=true to keep prompts out of the alt-screen — but that also gates
// out post-install *execution* (applyPostInstall skips when Silent), so a
// slug/RemoteConfig install streamed through the pipeline would silently drop a
// script the linear path would have previewed, confirmed, and run. The CLI
// defers it here with Silent cleared, so the script gets its preview + confirm
// and runs, matching the linear installer. No-op when there is no script.
func RunPostInstallAfterTUI(plan InstallPlan) error {
if len(plan.PostInstall) == 0 {
return nil
}
plan.Silent = false
if err := applyPostInstall(plan, ConsoleReporter{}); err != nil {
return fmt.Errorf("post-install: %w", err)
}
return nil
}

func showScreenRecordingReminderFromPlan(plan InstallPlan) {
if plan.DryRun || plan.Silent {
return
Expand Down
7 changes: 7 additions & 0 deletions internal/installer/installer_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ func TestShowScreenRecordingReminderFromPlan_DryRun_NoOp(t *testing.T) {
})
}

// RunPostInstallAfterTUI must be a no-op (no prompt, no error) when the plan has
// no post-install script — it's called on every pipeline install. The
// script-present path needs a TTY + real exec, so it's covered by e2e, not here.
func TestRunPostInstallAfterTUI_NoOpWhenEmpty(t *testing.T) {
assert.NoError(t, RunPostInstallAfterTUI(InstallPlan{}))
}

func TestShowScreenRecordingReminderFromPlan_Silent_NoOp(t *testing.T) {
plan := InstallPlan{DryRun: false, Silent: true}
assert.NotPanics(t, func() {
Expand Down
50 changes: 46 additions & 4 deletions internal/ui/tui/wizard/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package wizard

import (
"context"
"errors"
"fmt"
"strings"

Expand Down Expand Up @@ -111,7 +112,7 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) {
// spawnInstall runs the install engine on a background goroutine, streaming
// progress onto the event channel. It returns immediately.
func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea.Cmd {
ch := m.events
ch, done := m.events, m.installDone
return func() tea.Msg {
go func() {
sink := func(ev progress.Event) { ch <- evMsg{ev: ev} }
Expand All @@ -120,6 +121,11 @@ func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea
err := installer.ApplyContext(ctx, plan, chanReporter{ch: ch})
restoreBrew()
restoreNpm()
// Signal that the engine has stopped touching os.Stdout (ApplyContext
// returned) BEFORE the channel send, so Run can restore stdout without
// racing us — even on a force-quit where nothing drains the channel and
// the send below could block on a full buffer.
close(done)
ch <- installDoneMsg{err: err}
}()
return nil
Expand All @@ -146,7 +152,7 @@ func toPhaseStates(ps []PipelinePhase) []phaseState {
// path) on a goroutine, wiring the same brew/npm sinks as spawnInstall so
// package progress streams into the shared install screen.
func (m Model) startPipeline() tea.Cmd {
ch, run, ctx := m.events, m.pipelineRun, m.pipelineCtx
ch, done, run, ctx := m.events, m.installDone, m.pipelineRun, m.pipelineCtx
return func() tea.Msg {
go func() {
sink := func(ev progress.Event) { ch <- evMsg{ev: ev} }
Expand All @@ -158,6 +164,8 @@ func (m Model) startPipeline() tea.Cmd {
err := run(ctx, chanReporter{ch: ch})
restoreBrew()
restoreNpm()
// See spawnInstall: close before the send so Run can join safely.
close(done)
ch <- installDoneMsg{err: err}
}()
return nil
Expand Down Expand Up @@ -231,8 +239,14 @@ func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) {
m.installing = false
m.done = true
m.installErr = t.err
if m.aborting && m.installErr == nil {
m.installErr = ErrAborted
if m.aborting {
// A ctrl+c cancel SIGKILLs the in-flight brew/npm subprocess, so t.err
// is usually non-nil ("signal: killed"). The old `&& t.err == nil` guard
// therefore left ErrAborted unset for the common case (abort during the
// package phase), and the CLI misreported the abort as an install
// failure. Join so errors.Is(err, ErrAborted) holds while the underlying
// cause stays in the chain for the log.
m.installErr = errors.Join(ErrAborted, t.err)
}
if m.cancel != nil {
m.cancel()
Expand Down Expand Up @@ -267,6 +281,16 @@ func (m *Model) applyProgressEvent(ev progress.Event) {
m.curStep = ev.Name
}
case progress.StepOK:
// The npm outer retry (applyNpm) re-runs the install and re-emits an
// "already installed" skip for every package a prior attempt installed —
// breaking the one-terminal-event-per-package invariant. Ignore a skip for
// a package that already had a terminal event, so skippedPkgs (which the
// completion footer subtracts from the package total) can't be inflated
// past the total and render a negative "N packages".
if ev.Detail == progress.SkipDetail && m.terminalSeen[termKey(ev)] {
return
}
m.markTerminal(ev)
m.incPhase(ev.Phase)
text := ev.Name
if ev.Detail != "" {
Expand All @@ -279,11 +303,29 @@ func (m *Model) applyProgressEvent(ev progress.Event) {
m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted})
}
case progress.StepFail:
m.markTerminal(ev)
m.incPhase(ev.Phase)
m.appendLog(logLine{mark: "✗", markColor: cDanger, text: ev.Name + " (" + ev.Detail + ")", color: cDanger})
}
}

// termKey identifies a package's terminal event by phase + name. Empty for
// unnamed events (e.g. the batch StepStart), which are never deduped.
func termKey(ev progress.Event) string {
if ev.Name == "" {
return ""
}
return ev.Phase + "/" + ev.Name
}

// markTerminal records that a package produced a terminal event, so a later
// re-emitted skip for it (npm retry) can be recognised as a duplicate.
func (m *Model) markTerminal(ev progress.Event) {
if k := termKey(ev); k != "" {
m.terminalSeen[k] = true
}
}

// activatePhase marks phase name active and everything before it finished.
func (m *Model) activatePhase(name string) {
if name == "" {
Expand Down
Loading
Loading