Skip to content

Commit 8bc949d

Browse files
committed
feat: improve installation reliability with pre-checks, state tracking, and auto-retry
Add three critical reliability improvements to prevent installation failures: 1. Pre-installation dependency checks - Verify Homebrew and Git are installed before starting - Prompt user with installation commands if dependencies missing - Allow user to continue or cancel rather than silent failure 2. Stateful installation with resume support - Track installed packages in ~/.openboot/install_state.json - Skip already-installed packages on re-run (idempotent) - Enable seamless resume after network failures or interruptions - Show clear feedback: "Skipping N packages from previous install" 3. Automatic retry for network operations - Retry brew/npm installs up to 3 times with exponential backoff - Only retry on transient errors (timeouts, connection refused) - Transparent to user - only shows final success/failure - Prevents failures from temporary network issues These changes target the core goal: installations should not fail due to preventable issues. Users experience fewer interruptions and can safely re-run the installer without starting from scratch.
1 parent 22dcfe0 commit 8bc949d

File tree

4 files changed

+326
-24
lines changed

4 files changed

+326
-24
lines changed

internal/brew/brew.go

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -431,38 +431,80 @@ func printBrewOutput(output string, progress *ui.StickyProgress) {
431431
}
432432

433433
func installFormulaWithError(pkg string) string {
434-
cmd := exec.Command("brew", "install", pkg)
435-
output, err := cmd.CombinedOutput()
436-
if err != nil {
434+
maxAttempts := 3
435+
for attempt := 1; attempt <= maxAttempts; attempt++ {
436+
cmd := exec.Command("brew", "install", pkg)
437+
output, err := cmd.CombinedOutput()
438+
if err == nil {
439+
return ""
440+
}
441+
437442
outputStr := string(output)
438443
if strings.Contains(strings.ToLower(outputStr), "try again using") && strings.Contains(strings.ToLower(outputStr), "--cask") {
439444
cmd2 := exec.Command("brew", "install", "--cask", pkg)
440445
output2, err2 := cmd2.CombinedOutput()
441-
if err2 != nil {
442-
return parseBrewError(string(output2))
446+
if err2 == nil {
447+
return ""
443448
}
444-
return ""
449+
outputStr = string(output2)
450+
}
451+
452+
errMsg := parseBrewError(outputStr)
453+
if attempt < maxAttempts && isRetryableError(errMsg) {
454+
delay := time.Duration(attempt) * 2 * time.Second
455+
time.Sleep(delay)
456+
continue
445457
}
446-
return parseBrewError(outputStr)
458+
459+
return errMsg
447460
}
448-
return ""
461+
return "max retries exceeded"
462+
}
463+
464+
func isRetryableError(errMsg string) bool {
465+
retryableErrors := []string{
466+
"connection timed out",
467+
"connection refused",
468+
"no internet connection",
469+
"download corrupted",
470+
}
471+
for _, retryable := range retryableErrors {
472+
if strings.Contains(errMsg, retryable) {
473+
return true
474+
}
475+
}
476+
return false
449477
}
450478

451479
func installSmartCaskWithError(pkg string) string {
452-
cmd := exec.Command("brew", "install", "--cask", pkg)
453-
output, err := cmd.CombinedOutput()
454-
if err != nil {
480+
maxAttempts := 3
481+
for attempt := 1; attempt <= maxAttempts; attempt++ {
482+
cmd := exec.Command("brew", "install", "--cask", pkg)
483+
output, err := cmd.CombinedOutput()
484+
if err == nil {
485+
return ""
486+
}
487+
455488
cmd2 := exec.Command("brew", "install", pkg)
456489
output2, err2 := cmd2.CombinedOutput()
457-
if err2 != nil {
458-
errMsg := parseBrewError(string(output))
459-
if errMsg == "unknown error" {
460-
errMsg = parseBrewError(string(output2))
461-
}
462-
return errMsg
490+
if err2 == nil {
491+
return ""
492+
}
493+
494+
errMsg := parseBrewError(string(output))
495+
if errMsg == "unknown error" {
496+
errMsg = parseBrewError(string(output2))
463497
}
498+
499+
if attempt < maxAttempts && isRetryableError(errMsg) {
500+
delay := time.Duration(attempt) * 2 * time.Second
501+
time.Sleep(delay)
502+
continue
503+
}
504+
505+
return errMsg
464506
}
465-
return ""
507+
return "max retries exceeded"
466508
}
467509

468510
func parseBrewError(output string) string {

internal/installer/installer.go

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,56 @@ func runInstall(cfg *config.Config) error {
4545
fmt.Println()
4646
}
4747

48+
if err := checkDependencies(cfg); err != nil {
49+
return err
50+
}
51+
4852
if cfg.RemoteConfig != nil {
4953
return runCustomInstall(cfg)
5054
}
5155

5256
return runInteractiveInstall(cfg)
5357
}
5458

59+
func checkDependencies(cfg *config.Config) error {
60+
if cfg.DryRun {
61+
return nil
62+
}
63+
64+
hasIssues := false
65+
66+
if !brew.IsInstalled() {
67+
hasIssues = true
68+
ui.Warn("Homebrew is not installed")
69+
ui.Info("Homebrew is required to install packages")
70+
ui.Muted("Install with: /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")
71+
fmt.Println()
72+
}
73+
74+
gitName, gitEmail := system.GetExistingGitConfig()
75+
if gitName == "" || gitEmail == "" {
76+
if !cfg.PackagesOnly {
77+
hasIssues = true
78+
ui.Warn("Git user information is not configured")
79+
ui.Info("You'll be prompted to configure it during installation")
80+
fmt.Println()
81+
}
82+
}
83+
84+
if hasIssues && !cfg.Silent {
85+
cont, err := ui.Confirm("Continue with installation?", true)
86+
if err != nil {
87+
return err
88+
}
89+
if !cont {
90+
return fmt.Errorf("installation cancelled")
91+
}
92+
fmt.Println()
93+
}
94+
95+
return nil
96+
}
97+
5598
func runCustomInstall(cfg *config.Config) error {
5699
ui.Info(fmt.Sprintf("Custom config: @%s/%s", cfg.RemoteConfig.Username, cfg.RemoteConfig.Slug))
57100

@@ -295,14 +338,52 @@ func stepInstallPackages(cfg *config.Config) error {
295338
return nil
296339
}
297340

298-
ui.Info(fmt.Sprintf("Installing %d packages (%d CLI, %d GUI)...", total, len(cliPkgs), len(caskPkgs)))
341+
state, _ := loadState()
342+
343+
var newCli []string
344+
var newCask []string
345+
346+
if !cfg.DryRun {
347+
for _, pkg := range cliPkgs {
348+
if !state.isFormulaInstalled(pkg) {
349+
newCli = append(newCli, pkg)
350+
}
351+
}
352+
for _, pkg := range caskPkgs {
353+
if !state.isCaskInstalled(pkg) {
354+
newCask = append(newCask, pkg)
355+
}
356+
}
357+
358+
stateSkipped := (len(cliPkgs) - len(newCli)) + (len(caskPkgs) - len(newCask))
359+
if stateSkipped > 0 {
360+
ui.Muted(fmt.Sprintf("Skipping %d packages from previous install", stateSkipped))
361+
}
362+
363+
cliPkgs = newCli
364+
caskPkgs = newCask
365+
}
366+
367+
if len(cliPkgs)+len(caskPkgs) == 0 {
368+
ui.Success("All packages already installed!")
369+
fmt.Println()
370+
return nil
371+
}
372+
373+
ui.Info(fmt.Sprintf("Installing %d packages (%d CLI, %d GUI)...", len(cliPkgs)+len(caskPkgs), len(cliPkgs), len(caskPkgs)))
299374
fmt.Println()
300375

301376
if err := brew.InstallWithProgress(cliPkgs, caskPkgs, cfg.DryRun); err != nil {
302377
ui.Error(fmt.Sprintf("Some packages failed: %v", err))
303378
}
304379

305380
if !cfg.DryRun {
381+
for _, pkg := range cliPkgs {
382+
state.markFormula(pkg)
383+
}
384+
for _, pkg := range caskPkgs {
385+
state.markCask(pkg)
386+
}
306387
ui.Success("Package installation complete")
307388
}
308389
fmt.Println()
@@ -323,13 +404,44 @@ func stepInstallNpm(cfg *config.Config) error {
323404
return nil
324405
}
325406

407+
state, _ := loadState()
408+
409+
var newNpm []string
410+
if !cfg.DryRun {
411+
for _, pkg := range npmPkgs {
412+
if !state.isNpmInstalled(pkg) {
413+
newNpm = append(newNpm, pkg)
414+
}
415+
}
416+
417+
stateSkipped := len(npmPkgs) - len(newNpm)
418+
if stateSkipped > 0 {
419+
ui.Muted(fmt.Sprintf("Skipping %d npm packages from previous install", stateSkipped))
420+
}
421+
422+
npmPkgs = newNpm
423+
}
424+
425+
if len(npmPkgs) == 0 {
426+
ui.Success("All npm packages already installed!")
427+
return nil
428+
}
429+
326430
fmt.Println()
327431
ui.Header("NPM Global Packages")
328432
fmt.Println()
329433
ui.Info(fmt.Sprintf("Installing %d npm packages...", len(npmPkgs)))
330434
fmt.Println()
331435

332-
return npm.Install(npmPkgs, cfg.DryRun)
436+
err := npm.Install(npmPkgs, cfg.DryRun)
437+
438+
if !cfg.DryRun && err == nil {
439+
for _, pkg := range npmPkgs {
440+
state.markNpm(pkg)
441+
}
442+
}
443+
444+
return err
333445
}
334446

335447
func stepInstallNpmWithRetry(cfg *config.Config) error {

internal/installer/state.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package installer
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
)
10+
11+
type InstallState struct {
12+
LastUpdated time.Time `json:"last_updated"`
13+
InstalledFormulae map[string]bool `json:"installed_formulae"`
14+
InstalledCasks map[string]bool `json:"installed_casks"`
15+
InstalledNpm map[string]bool `json:"installed_npm"`
16+
}
17+
18+
func newInstallState() *InstallState {
19+
return &InstallState{
20+
LastUpdated: time.Now(),
21+
InstalledFormulae: make(map[string]bool),
22+
InstalledCasks: make(map[string]bool),
23+
InstalledNpm: make(map[string]bool),
24+
}
25+
}
26+
27+
func getStatePath() (string, error) {
28+
home, err := os.UserHomeDir()
29+
if err != nil {
30+
return "", fmt.Errorf("failed to get home directory: %w", err)
31+
}
32+
return filepath.Join(home, ".openboot", "install_state.json"), nil
33+
}
34+
35+
func loadState() (*InstallState, error) {
36+
path, err := getStatePath()
37+
if err != nil {
38+
return newInstallState(), err
39+
}
40+
41+
data, err := os.ReadFile(path)
42+
if err != nil {
43+
if os.IsNotExist(err) {
44+
return newInstallState(), nil
45+
}
46+
return newInstallState(), err
47+
}
48+
49+
var state InstallState
50+
if err := json.Unmarshal(data, &state); err != nil {
51+
return newInstallState(), err
52+
}
53+
54+
if state.InstalledFormulae == nil {
55+
state.InstalledFormulae = make(map[string]bool)
56+
}
57+
if state.InstalledCasks == nil {
58+
state.InstalledCasks = make(map[string]bool)
59+
}
60+
if state.InstalledNpm == nil {
61+
state.InstalledNpm = make(map[string]bool)
62+
}
63+
64+
return &state, nil
65+
}
66+
67+
func (s *InstallState) save() error {
68+
path, err := getStatePath()
69+
if err != nil {
70+
return err
71+
}
72+
73+
dir := filepath.Dir(path)
74+
if err := os.MkdirAll(dir, 0755); err != nil {
75+
return fmt.Errorf("failed to create directory: %w", err)
76+
}
77+
78+
s.LastUpdated = time.Now()
79+
80+
data, err := json.MarshalIndent(s, "", " ")
81+
if err != nil {
82+
return err
83+
}
84+
85+
return os.WriteFile(path, data, 0644)
86+
}
87+
88+
func (s *InstallState) markFormula(name string) error {
89+
s.InstalledFormulae[name] = true
90+
return s.save()
91+
}
92+
93+
func (s *InstallState) markCask(name string) error {
94+
s.InstalledCasks[name] = true
95+
return s.save()
96+
}
97+
98+
func (s *InstallState) markNpm(name string) error {
99+
s.InstalledNpm[name] = true
100+
return s.save()
101+
}
102+
103+
func (s *InstallState) isFormulaInstalled(name string) bool {
104+
return s.InstalledFormulae[name]
105+
}
106+
107+
func (s *InstallState) isCaskInstalled(name string) bool {
108+
return s.InstalledCasks[name]
109+
}
110+
111+
func (s *InstallState) isNpmInstalled(name string) bool {
112+
return s.InstalledNpm[name]
113+
}

0 commit comments

Comments
 (0)