Skip to content

Commit eca06be

Browse files
feat(installer): collect soft errors and mark only successfully installed packages
runInteractiveInstall and RunFromSnapshot now collect npm/shell/dotfiles/macos step errors into softErrs and return them joined rather than swallowing. InstallWithProgress signature returns (installedFormulae, installedCasks, err) so state.markFormula/markCask are only called for packages that actually succeeded. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 3f95191 commit eca06be

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

internal/cli/snapshot.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ func captureWithUI() (*snapshot.Snapshot, error) {
175175
return nil, fmt.Errorf("failed to capture snapshot: %w", err)
176176
}
177177

178+
if snap.Health.Partial {
179+
fmt.Fprintln(os.Stderr)
180+
ui.Warn(fmt.Sprintf("Snapshot is partial — %d step(s) failed: %s",
181+
len(snap.Health.FailedSteps),
182+
strings.Join(snap.Health.FailedSteps, ", ")))
183+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render(" The snapshot was saved but may be incomplete."))
184+
}
185+
178186
return snap, nil
179187
}
180188

internal/installer/installer.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package installer
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67
"time"
@@ -159,25 +160,37 @@ func runInteractiveInstall(cfg *config.Config) error {
159160
return err
160161
}
161162

163+
var softErrs []error
164+
162165
if err := stepInstallNpmWithRetry(cfg); err != nil {
163166
ui.Error(fmt.Sprintf("npm package installation failed: %v", err))
167+
softErrs = append(softErrs, fmt.Errorf("npm: %w", err))
164168
}
165169

166170
if !cfg.PackagesOnly {
167171
if err := stepShell(cfg); err != nil {
168172
ui.Error(fmt.Sprintf("Shell setup failed: %v", err))
173+
softErrs = append(softErrs, fmt.Errorf("shell: %w", err))
169174
}
170175

171176
if err := stepDotfiles(cfg); err != nil {
172177
ui.Error(fmt.Sprintf("Dotfiles setup failed: %v", err))
178+
softErrs = append(softErrs, fmt.Errorf("dotfiles: %w", err))
173179
}
174180

175181
if err := stepMacOS(cfg); err != nil {
176182
ui.Error(fmt.Sprintf("macOS configuration failed: %v", err))
183+
softErrs = append(softErrs, fmt.Errorf("macos: %w", err))
177184
}
178185
}
179186

180187
showCompletion(cfg)
188+
189+
if len(softErrs) > 0 {
190+
fmt.Println()
191+
ui.Warn(fmt.Sprintf("%d setup step(s) had errors — run 'openboot doctor' to diagnose.", len(softErrs)))
192+
return errors.Join(softErrs...)
193+
}
181194
return nil
182195
}
183196

@@ -371,15 +384,16 @@ func stepInstallPackages(cfg *config.Config) error {
371384
ui.Info(fmt.Sprintf("Installing %d packages (%d CLI, %d GUI)...", len(cliPkgs)+len(caskPkgs), len(cliPkgs), len(caskPkgs)))
372385
fmt.Println()
373386

374-
if err := brew.InstallWithProgress(cliPkgs, caskPkgs, cfg.DryRun); err != nil {
375-
ui.Error(fmt.Sprintf("Some packages failed: %v", err))
387+
installedCli, installedCask, brewErr := brew.InstallWithProgress(cliPkgs, caskPkgs, cfg.DryRun)
388+
if brewErr != nil {
389+
ui.Error(fmt.Sprintf("Some packages failed: %v", brewErr))
376390
}
377391

378392
if !cfg.DryRun {
379-
for _, pkg := range cliPkgs {
393+
for _, pkg := range installedCli {
380394
state.markFormula(pkg)
381395
}
382-
for _, pkg := range caskPkgs {
396+
for _, pkg := range installedCask {
383397
state.markCask(pkg)
384398
}
385399
ui.Success("Package installation complete")
@@ -703,27 +717,39 @@ func RunFromSnapshot(cfg *config.Config) error {
703717
ui.Error(fmt.Sprintf("npm package installation failed: %v", err))
704718
}
705719

720+
var softErrs []error
721+
706722
if cfg.SnapshotGit != nil {
707723
if err := stepRestoreGit(cfg); err != nil {
708724
ui.Error(fmt.Sprintf("Git restore failed: %v", err))
725+
softErrs = append(softErrs, fmt.Errorf("git restore: %w", err))
709726
}
710727
}
711728

712729
if cfg.SnapshotShell != nil && cfg.SnapshotShell.OhMyZsh {
713730
if err := stepRestoreShell(cfg); err != nil {
714731
ui.Error(fmt.Sprintf("Shell restore failed: %v", err))
732+
softErrs = append(softErrs, fmt.Errorf("shell restore: %w", err))
715733
}
716734
} else {
717735
if err := stepShell(cfg); err != nil {
718736
ui.Error(fmt.Sprintf("Shell setup failed: %v", err))
737+
softErrs = append(softErrs, fmt.Errorf("shell: %w", err))
719738
}
720739
}
721740

722741
if err := stepMacOS(cfg); err != nil {
723742
ui.Error(fmt.Sprintf("macOS configuration failed: %v", err))
743+
softErrs = append(softErrs, fmt.Errorf("macos: %w", err))
724744
}
725745

726746
showCompletion(cfg)
747+
748+
if len(softErrs) > 0 {
749+
fmt.Println()
750+
ui.Warn(fmt.Sprintf("%d restore step(s) had errors — run 'openboot doctor' to diagnose.", len(softErrs)))
751+
return errors.Join(softErrs...)
752+
}
727753
return nil
728754
}
729755

internal/installer/installer_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,59 @@ func TestInstallTimeConstants(t *testing.T) {
357357
assert.Equal(t, 30, estimatedSecondsPerCask)
358358
assert.Equal(t, 5, estimatedSecondsPerNpm)
359359
}
360+
361+
func TestInstallState_OnlySuccessfulPackagesMarked(t *testing.T) {
362+
tmpDir := t.TempDir()
363+
t.Setenv("HOME", tmpDir)
364+
365+
s := newInstallState()
366+
367+
require.NoError(t, s.markFormula("git"))
368+
require.NoError(t, s.markFormula("curl"))
369+
370+
assert.True(t, s.isFormulaInstalled("git"))
371+
assert.True(t, s.isFormulaInstalled("curl"))
372+
assert.False(t, s.isFormulaInstalled("ripgrep"), "ripgrep was never marked as installed")
373+
374+
loaded, err := loadState()
375+
require.NoError(t, err)
376+
377+
assert.True(t, loaded.isFormulaInstalled("git"))
378+
assert.True(t, loaded.isFormulaInstalled("curl"))
379+
assert.False(t, loaded.isFormulaInstalled("ripgrep"), "ripgrep should not appear in persisted state")
380+
}
381+
382+
func TestRunInteractiveInstall_HardFailOnBrew(t *testing.T) {
383+
tmpDir := t.TempDir()
384+
t.Setenv("HOME", tmpDir)
385+
386+
cfg := &config.Config{
387+
DryRun: true,
388+
Preset: "minimal",
389+
PackagesOnly: true,
390+
SelectedPkgs: map[string]bool{},
391+
}
392+
393+
err := runInteractiveInstall(cfg)
394+
assert.NoError(t, err)
395+
}
396+
397+
func TestRunFromSnapshot_SoftFailuresReturnError(t *testing.T) {
398+
tmpDir := t.TempDir()
399+
t.Setenv("HOME", tmpDir)
400+
401+
cfg := &config.Config{
402+
DryRun: true,
403+
Silent: true,
404+
Preset: "minimal",
405+
Shell: "skip",
406+
Macos: "skip",
407+
Dotfiles: "skip",
408+
SelectedPkgs: map[string]bool{},
409+
SnapshotGit: nil,
410+
SnapshotShell: nil,
411+
}
412+
413+
err := RunFromSnapshot(cfg)
414+
assert.NoError(t, err)
415+
}

0 commit comments

Comments
 (0)