From c933f00d00a65637e29d1a9754aac0abb9751eaf Mon Sep 17 00:00:00 2001 From: Vito Castellano Date: Sun, 28 Dec 2025 12:45:21 +0100 Subject: [PATCH] feat(brewfile): add tap support with auto-installation and caching (#43) Add support for parsing and installing taps from Brewfile entries. Taps are automatically installed at startup before updating Homebrew data. Tap package information is cached for faster subsequent startups. Also fixes thread safety issues with UI notifications in background goroutines and improves error handling for brew command execution. --- internal/models/brewfile.go | 6 + internal/services/app.go | 269 +++++++++++++++++++++++++++++++---- internal/services/brew.go | 271 +++++++++++++++++++++++++++++++++++- 3 files changed, 512 insertions(+), 34 deletions(-) diff --git a/internal/models/brewfile.go b/internal/models/brewfile.go index caaca02..78061fb 100644 --- a/internal/models/brewfile.go +++ b/internal/models/brewfile.go @@ -5,3 +5,9 @@ type BrewfileEntry struct { Name string IsCask bool } + +// BrewfileResult contains all parsed entries from a Brewfile +type BrewfileResult struct { + Taps []string // List of taps to install + Packages []BrewfileEntry // List of packages (formulae and casks) +} diff --git a/internal/services/app.go b/internal/services/app.go index 93d06d6..bacd6ad 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -47,6 +47,7 @@ type AppService struct { // Brewfile support brewfilePath string brewfilePackages *[]models.Package + brewfileTaps []string // Taps required by the Brewfile brewService BrewServiceInterface selfUpdateService SelfUpdateServiceInterface @@ -97,9 +98,8 @@ func (s *AppService) Boot() (err error) { return fmt.Errorf("failed to get Homebrew version: %v", err) } - // Download and parse Homebrew formulae data - // Non-critical: if this fails (corrupted cache + no internet), app will start with empty data - // and background update will populate it when network is available + // Load Homebrew data from cache for fast startup + // Installation status might be stale but will be refreshed in background by updateHomeBrew() if err = s.brewService.SetupData(false); err != nil { // Log error but don't fail - app can work with empty/partial data fmt.Fprintf(os.Stderr, "Warning: failed to load Homebrew data (will retry in background): %v\n", err) @@ -119,16 +119,20 @@ func (s *AppService) Boot() (err error) { return nil } -// loadBrewfilePackages parses the Brewfile and creates a filtered package list +// loadBrewfilePackages parses the Brewfile and creates a filtered package list. +// Packages not found in s.packages are loaded from cache, or show "(loading...)" if not cached. func (s *AppService) loadBrewfilePackages() error { - entries, err := s.brewService.ParseBrewfile(s.brewfilePath) + result, err := s.brewService.ParseBrewfileWithTaps(s.brewfilePath) if err != nil { return err } - // Create a map for quick lookup + // Store taps for later installation + s.brewfileTaps = result.Taps + + // Create a map for quick lookup of Brewfile entries packageMap := make(map[string]models.PackageType) - for _, entry := range entries { + for _, entry := range result.Packages { if entry.IsCask { packageMap[entry.Name] = models.PackageTypeCask } else { @@ -136,26 +140,216 @@ func (s *AppService) loadBrewfilePackages() error { } } + // Track which packages were found in the main package list + foundPackages := make(map[string]bool) + + // Get actual installed packages (2 calls total, much faster than per-package checks) + installedCasks := s.brewService.GetInstalledCaskNames() + installedFormulae := s.brewService.GetInstalledFormulaNames() + // Filter packages to only include those in the Brewfile *s.brewfilePackages = []models.Package{} for _, pkg := range *s.packages { if pkgType, exists := packageMap[pkg.Name]; exists && pkgType == pkg.Type { + // Verify installation status against actual installed lists + if pkgType == models.PackageTypeCask { + pkg.LocallyInstalled = installedCasks[pkg.Name] + } else { + pkg.LocallyInstalled = installedFormulae[pkg.Name] + } *s.brewfilePackages = append(*s.brewfilePackages, pkg) + foundPackages[pkg.Name] = true } } + // Load tap packages cache for packages not found in main list + tapCache := s.brewService.LoadTapPackagesCache() + + // For packages not found, try cache first, then show "(loading...)" + for _, entry := range result.Packages { + if foundPackages[entry.Name] { + continue + } + + // Try to get from cache + if cachedPkg, exists := tapCache[entry.Name]; exists { + // Update installation status from local system + if entry.IsCask { + cachedPkg.LocallyInstalled = s.brewService.IsPackageInstalled(entry.Name, true) + } else { + cachedPkg.LocallyInstalled = s.brewService.IsPackageInstalled(entry.Name, false) + } + *s.brewfilePackages = append(*s.brewfilePackages, cachedPkg) + continue + } + + // Not in cache - show placeholder + pkgType := models.PackageTypeFormula + if entry.IsCask { + pkgType = models.PackageTypeCask + } + *s.brewfilePackages = append(*s.brewfilePackages, models.Package{ + Name: entry.Name, + DisplayName: entry.Name, + Description: "(loading...)", + Type: pkgType, + }) + } + + // Sort by name for consistent display + sort.Slice(*s.brewfilePackages, func(i, j int) bool { + return (*s.brewfilePackages)[i].Name < (*s.brewfilePackages)[j].Name + }) + return nil } +// fetchTapPackages fetches info for packages from third-party taps and adds them to s.packages. +// This is called after taps are installed so that loadBrewfilePackages can find them. +// It also saves the fetched data to cache for faster startup next time. +func (s *AppService) fetchTapPackages() { + if !s.IsBrewfileMode() || len(s.brewfileTaps) == 0 { + return + } + + result, err := s.brewService.ParseBrewfileWithTaps(s.brewfilePath) + if err != nil { + return + } + + // Build a map of existing packages for quick lookup + existingPackages := make(map[string]models.Package) + for _, pkg := range *s.packages { + existingPackages[pkg.Name] = pkg + } + + // Collect packages not in s.packages (need to fetch) and packages already present (for cache) + var missingCasks []string + var missingFormulae []string + var presentPackages []models.Package // Packages already in s.packages (installed tap packages) + + for _, entry := range result.Packages { + if pkg, exists := existingPackages[entry.Name]; exists { + // Package is already in s.packages (likely installed) + // Save it to cache so it's available after uninstall + presentPackages = append(presentPackages, pkg) + } else { + // Package is missing, need to fetch + if entry.IsCask { + missingCasks = append(missingCasks, entry.Name) + } else { + missingFormulae = append(missingFormulae, entry.Name) + } + } + } + + // Collect all tap packages to save to cache (both fetched and already present) + tapPackages := append([]models.Package{}, presentPackages...) + + // Fetch and add missing casks + if len(missingCasks) > 0 { + caskInfo := s.brewService.GetPackagesInfo(missingCasks, true) + for _, name := range missingCasks { + if pkg, exists := caskInfo[name]; exists { + *s.packages = append(*s.packages, pkg) + tapPackages = append(tapPackages, pkg) + } else { + // Add fallback entry if brew info failed + fallback := models.Package{ + Name: name, + DisplayName: name, + Description: "(unable to load package info)", + Type: models.PackageTypeCask, + } + *s.packages = append(*s.packages, fallback) + tapPackages = append(tapPackages, fallback) + } + } + } + + // Fetch and add missing formulae + if len(missingFormulae) > 0 { + formulaInfo := s.brewService.GetPackagesInfo(missingFormulae, false) + for _, name := range missingFormulae { + if pkg, exists := formulaInfo[name]; exists { + *s.packages = append(*s.packages, pkg) + tapPackages = append(tapPackages, pkg) + } else { + // Add fallback entry if brew info failed + fallback := models.Package{ + Name: name, + DisplayName: name, + Description: "(unable to load package info)", + Type: models.PackageTypeFormula, + } + *s.packages = append(*s.packages, fallback) + tapPackages = append(tapPackages, fallback) + } + } + } + + // Save ALL tap packages to cache (including already installed ones) + if len(tapPackages) > 0 { + _ = s.brewService.SaveTapPackagesToCache(tapPackages) + } +} + +// installBrewfileTapsAtStartup installs any missing taps from the Brewfile at app startup. +// This runs before updateHomeBrew, which will then reload all data including the new taps. +func (s *AppService) installBrewfileTapsAtStartup() { + // Check which taps need to be installed + var tapsToInstall []string + for _, tap := range s.brewfileTaps { + if !s.brewService.IsTapInstalled(tap) { + tapsToInstall = append(tapsToInstall, tap) + } + } + + if len(tapsToInstall) == 0 { + return // All taps already installed + } + + // Install missing taps + for _, tap := range tapsToInstall { + tap := tap // Create local copy for closures + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Installing tap %s...", tap)) + fmt.Fprintf(s.layout.GetOutput().View(), "[TAP] Installing %s...\n", tap) + }) + + if err := s.brewService.InstallTap(tap, s.app, s.layout.GetOutput().View()); err != nil { + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to install tap %s", tap)) + fmt.Fprintf(s.layout.GetOutput().View(), "[ERROR] Failed to install tap %s\n", tap) + }) + } else { + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Tap %s installed", tap)) + fmt.Fprintf(s.layout.GetOutput().View(), "[SUCCESS] tap %s installed\n", tap) + }) + } + } + + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowSuccess("All taps installed") + }) +} + // updateHomeBrew updates the Homebrew formulae and refreshes the results in the UI. func (s *AppService) updateHomeBrew() { - s.layout.GetNotifier().ShowWarning("Updating Homebrew formulae...") + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowWarning("Updating Homebrew formulae...") + }) if err := s.brewService.UpdateHomebrew(); err != nil { - s.layout.GetNotifier().ShowError("Could not update Homebrew formulae") + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowError("Could not update Homebrew formulae") + }) return } // Clear loading message and update results - s.layout.GetNotifier().ShowSuccess("Homebrew formulae updated successfully") + s.app.QueueUpdateDraw(func() { + s.layout.GetNotifier().ShowSuccess("Homebrew formulae updated successfully") + }) s.forceRefreshResults() } @@ -246,14 +440,27 @@ func (s *AppService) search(searchText string, scrollToTop bool) { // forceRefreshResults forces a refresh of the Homebrew formulae and cask data and updates the results in the UI. func (s *AppService) forceRefreshResults() { - _ = s.brewService.SetupData(true) + // Use cached API data (fast) - only installed status needs refresh + _ = s.brewService.SetupData(false) s.packages = s.brewService.GetPackages() - // If in Brewfile mode, reload the filtered packages + // If in Brewfile mode, load tap packages and verify installed status if s.IsBrewfileMode() { - _ = s.loadBrewfilePackages() + s.fetchTapPackages() + _ = s.loadBrewfilePackages() // Gets fresh installed status via GetInstalledCaskNames/FormulaNames *s.filteredPackages = *s.brewfilePackages } else { + // For non-Brewfile mode, get fresh installed status + installedCasks := s.brewService.GetInstalledCaskNames() + installedFormulae := s.brewService.GetInstalledFormulaNames() + for i := range *s.packages { + pkg := &(*s.packages)[i] + if pkg.Type == models.PackageTypeCask { + pkg.LocallyInstalled = installedCasks[pkg.Name] + } else { + pkg.LocallyInstalled = installedFormulae[pkg.Name] + } + } *s.filteredPackages = *s.packages } @@ -302,21 +509,21 @@ func (s *AppService) setResults(data *[]models.Package, scrollToTop bool) { } // Update the details view with the first item in the list - if len(*data) > 0 { - if scrollToTop { - s.layout.GetTable().View().Select(1, 0) - s.layout.GetTable().View().ScrollToBeginning() - s.layout.GetDetails().SetContent(&(*data)[0]) - } - - // Update the filter counter - s.layout.GetSearch().UpdateCounter(len(*s.packages), len(*s.filteredPackages)) - return + if len(*data) > 0 && scrollToTop { + s.layout.GetTable().View().Select(1, 0) + s.layout.GetTable().View().ScrollToBeginning() + s.layout.GetDetails().SetContent(&(*data)[0]) + } else if len(*data) == 0 { + s.layout.GetDetails().SetContent(nil) // Clear details if no results } - // Update the filter counter even if no results are found - s.layout.GetSearch().UpdateCounter(len(*s.packages), len(*s.filteredPackages)) - s.layout.GetDetails().SetContent(nil) // Clear details if no results + // Update the filter counter + // In Brewfile mode, show total Brewfile packages instead of all packages + totalCount := len(*s.packages) + if s.IsBrewfileMode() { + totalCount = len(*s.brewfilePackages) + } + s.layout.GetSearch().UpdateCounter(totalCount, len(*s.filteredPackages)) } // BuildApp builds the application layout, sets up event handlers, and initializes the UI components. @@ -379,7 +586,15 @@ func (s *AppService) BuildApp() { s.app.SetRoot(s.layout.Root(), true) s.app.SetFocus(s.layout.GetTable().View()) - go s.updateHomeBrew() // Update Async the Homebrew formulae + // Start background tasks: install taps first (if Brewfile mode), then update Homebrew + go func() { + // In Brewfile mode, install missing taps first + if s.IsBrewfileMode() && len(s.brewfileTaps) > 0 { + s.installBrewfileTapsAtStartup() + } + // Then update Homebrew (which will reload all data including new taps) + s.updateHomeBrew() + }() // Set initial results based on mode if s.IsBrewfileMode() { diff --git a/internal/services/brew.go b/internal/services/brew.go index 2d519ab..133fbba 100644 --- a/internal/services/brew.go +++ b/internal/services/brew.go @@ -43,6 +43,15 @@ type BrewServiceInterface interface { InstallAllPackages(packages []models.Package, app *tview.Application, outputView *tview.TextView) error RemoveAllPackages(packages []models.Package, app *tview.Application, outputView *tview.TextView) error ParseBrewfile(filepath string) ([]models.BrewfileEntry, error) + ParseBrewfileWithTaps(filepath string) (*models.BrewfileResult, error) + InstallTap(tapName string, app *tview.Application, outputView *tview.TextView) error + IsTapInstalled(tapName string) bool + IsPackageInstalled(name string, isCask bool) bool + GetInstalledCaskNames() map[string]bool + GetInstalledFormulaNames() map[string]bool + GetPackagesInfo(names []string, isCask bool) map[string]models.Package + LoadTapPackagesCache() map[string]models.Package + SaveTapPackagesToCache(packages []models.Package) error } // BrewService provides methods to interact with Homebrew, including @@ -685,12 +694,15 @@ func (s *BrewService) executeCommand( var wg sync.WaitGroup wg.Add(3) + // Channel to capture the command exit error + cmdErrCh := make(chan error, 1) + // Goroutine to wait for the command to finish go func() { defer wg.Done() defer stdoutWriter.Close() defer stderrWriter.Close() - _ = cmd.Wait() // #nosec G104 -- Error is handled by pipe readers below + cmdErrCh <- cmd.Wait() }() // Stdout handler @@ -746,19 +758,34 @@ func (s *BrewService) executeCommand( }() wg.Wait() - return nil + + // Return the command exit error (nil if successful) + return <-cmdErrCh } // ParseBrewfile parses a Brewfile and returns a list of packages to be installed. -// It handles both 'brew' and 'cask' entries in the Brewfile format. +// It handles 'tap', 'brew' and 'cask' entries in the Brewfile format. func (s *BrewService) ParseBrewfile(filepath string) ([]models.BrewfileEntry, error) { + result, err := s.ParseBrewfileWithTaps(filepath) + if err != nil { + return nil, err + } + return result.Packages, nil +} + +// ParseBrewfileWithTaps parses a Brewfile and returns taps and packages separately. +// This allows installing taps before packages. +func (s *BrewService) ParseBrewfileWithTaps(filepath string) (*models.BrewfileResult, error) { // #nosec G304 -- filepath is user-provided via CLI flag data, err := os.ReadFile(filepath) if err != nil { return nil, fmt.Errorf("failed to read Brewfile: %w", err) } - var entries []models.BrewfileEntry + result := &models.BrewfileResult{ + Taps: []string{}, + Packages: []models.BrewfileEntry{}, + } lines := strings.Split(string(data), "\n") for _, line := range lines { @@ -769,6 +796,16 @@ func (s *BrewService) ParseBrewfile(filepath string) ([]models.BrewfileEntry, er continue } + // Parse tap entries: tap "user/repo" + if strings.HasPrefix(line, "tap ") { + start := strings.Index(line, "\"") + end := strings.LastIndex(line, "\"") + if start != -1 && end != -1 && start < end { + tapName := line[start+1 : end] + result.Taps = append(result.Taps, tapName) + } + } + // Parse brew entries: brew "package-name" if strings.HasPrefix(line, "brew ") { // Extract package name from quotes @@ -776,7 +813,7 @@ func (s *BrewService) ParseBrewfile(filepath string) ([]models.BrewfileEntry, er end := strings.LastIndex(line, "\"") if start != -1 && end != -1 && start < end { packageName := line[start+1 : end] - entries = append(entries, models.BrewfileEntry{ + result.Packages = append(result.Packages, models.BrewfileEntry{ Name: packageName, IsCask: false, }) @@ -790,7 +827,7 @@ func (s *BrewService) ParseBrewfile(filepath string) ([]models.BrewfileEntry, er end := strings.LastIndex(line, "\"") if start != -1 && end != -1 && start < end { packageName := line[start+1 : end] - entries = append(entries, models.BrewfileEntry{ + result.Packages = append(result.Packages, models.BrewfileEntry{ Name: packageName, IsCask: true, }) @@ -798,7 +835,175 @@ func (s *BrewService) ParseBrewfile(filepath string) ([]models.BrewfileEntry, er } } - return entries, nil + return result, nil +} + +// InstallTap installs a Homebrew tap. +func (s *BrewService) InstallTap(tapName string, app *tview.Application, outputView *tview.TextView) error { + cmd := exec.Command("brew", "tap", tapName) // #nosec G204 + return s.executeCommand(app, cmd, outputView) +} + +// IsTapInstalled checks if a tap is already installed. +func (s *BrewService) IsTapInstalled(tapName string) bool { + cmd := exec.Command("brew", "tap") + output, err := cmd.Output() + if err != nil { + return false + } + + taps := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, tap := range taps { + if strings.TrimSpace(tap) == tapName { + return true + } + } + return false +} + +// IsPackageInstalled checks if a package (formula or cask) is installed by name. +func (s *BrewService) IsPackageInstalled(name string, isCask bool) bool { + var cmd *exec.Cmd + if isCask { + cmd = exec.Command("brew", "list", "--cask", name) + } else { + cmd = exec.Command("brew", "list", "--formula", name) + } + err := cmd.Run() + return err == nil +} + +// GetInstalledCaskNames returns a map of installed cask names for quick lookup. +func (s *BrewService) GetInstalledCaskNames() map[string]bool { + result := make(map[string]bool) + cmd := exec.Command("brew", "list", "--cask") + output, err := cmd.Output() + if err != nil { + return result + } + names := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, name := range names { + if name != "" { + result[name] = true + } + } + return result +} + +// GetInstalledFormulaNames returns a map of installed formula names for quick lookup. +func (s *BrewService) GetInstalledFormulaNames() map[string]bool { + result := make(map[string]bool) + cmd := exec.Command("brew", "list", "--formula") + output, err := cmd.Output() + if err != nil { + return result + } + names := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, name := range names { + if name != "" { + result[name] = true + } + } + return result +} + +// getPackageInfoSingle retrieves info for a single package directly. +// Used internally as fallback when batch call fails. +func (s *BrewService) getPackageInfoSingle(name string, isCask bool) *models.Package { + var cmd *exec.Cmd + if isCask { + cmd = exec.Command("brew", "info", "--json=v2", "--cask", name) + } else { + cmd = exec.Command("brew", "info", "--json=v1", name) + } + + output, err := cmd.Output() + if err != nil { + return nil + } + + if isCask { + var response struct { + Casks []models.Cask `json:"casks"` + } + if err := json.Unmarshal(output, &response); err != nil || len(response.Casks) == 0 { + return nil + } + cask := response.Casks[0] + cask.LocallyInstalled = s.IsPackageInstalled(name, true) + pkg := models.NewPackageFromCask(&cask) + return &pkg + } + + var formulae []models.Formula + if err := json.Unmarshal(output, &formulae); err != nil || len(formulae) == 0 { + return nil + } + formula := formulae[0] + formula.LocallyInstalled = s.IsPackageInstalled(name, false) + pkg := models.NewPackageFromFormula(&formula) + return &pkg +} + +// GetPackagesInfo retrieves package information for multiple packages in a single brew call. +// This is much faster than calling GetPackageInfo for each package individually. +// If the batch call fails, it falls back to individual calls for each package. +// Returns a map of package name to Package. +func (s *BrewService) GetPackagesInfo(names []string, isCask bool) map[string]models.Package { + result := make(map[string]models.Package) + if len(names) == 0 { + return result + } + + var cmd *exec.Cmd + if isCask { + args := append([]string{"info", "--json=v2", "--cask"}, names...) + cmd = exec.Command("brew", args...) + } else { + args := append([]string{"info", "--json=v1"}, names...) + cmd = exec.Command("brew", args...) + } + + output, err := cmd.Output() + if err != nil { + // Batch call failed - try each package individually + for _, name := range names { + if pkg := s.getPackageInfoSingle(name, isCask); pkg != nil { + result[name] = *pkg + } + } + return result + } + + if isCask { + // Parse cask JSON (v2 format) + var response struct { + Casks []models.Cask `json:"casks"` + } + if err := json.Unmarshal(output, &response); err != nil { + return result + } + for _, cask := range response.Casks { + c := cask + c.LocallyInstalled = s.IsPackageInstalled(c.Token, true) + pkg := models.NewPackageFromCask(&c) + result[c.Token] = pkg + } + } else { + // Parse formula JSON (v1 format) + var formulae []models.Formula + if err := json.Unmarshal(output, &formulae); err != nil { + return result + } + for _, formula := range formulae { + f := formula + f.LocallyInstalled = s.IsPackageInstalled(f.Name, false) + pkg := models.NewPackageFromFormula(&f) + result[f.Name] = pkg + } + } + + return result } // InstallAllPackages installs a list of packages sequentially. @@ -864,3 +1069,55 @@ func (s *BrewService) RemoveAllPackages(packages []models.Package, app *tview.Ap return nil } + +// LoadTapPackagesCache loads cached tap packages from disk. +// Returns a map of package name to Package, or empty map if cache doesn't exist. +func (s *BrewService) LoadTapPackagesCache() map[string]models.Package { + result := make(map[string]models.Package) + + cacheDir := getCacheDir() + tapPackagesFile := filepath.Join(cacheDir, "tap_packages.json") + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + return result // Cache directory doesn't exist + } + + // Check if cache file exists and has reasonable size + if fileInfo, err := os.Stat(tapPackagesFile); err == nil { + // Only use cache if file has reasonable size (> 10 bytes for minimal JSON "[]") + if fileInfo.Size() > 10 { + // #nosec G304 -- tapPackagesFile path is safely constructed from getCacheDir and sanitized with filepath.Join + data, err := os.ReadFile(tapPackagesFile) + if err == nil && len(data) > 0 { + var packages []models.Package + if err := json.Unmarshal(data, &packages); err == nil && len(packages) > 0 { + // Convert to map for quick lookup + for _, pkg := range packages { + result[pkg.Name] = pkg + } + return result + } + } + } + } + + return result +} + +// SaveTapPackagesToCache saves tap packages to disk cache. +func (s *BrewService) SaveTapPackagesToCache(packages []models.Package) error { + cacheDir := getCacheDir() + tapPackagesFile := filepath.Join(cacheDir, "tap_packages.json") + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + if err := os.MkdirAll(cacheDir, 0750); err != nil { + return err + } + } + + data, err := json.Marshal(packages) + if err != nil { + return err + } + + // Cache the tap packages data + return os.WriteFile(tapPackagesFile, data, 0600) +}