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) +}