feat(brewfile): add tap support with auto-installation and caching (#43)
Some checks are pending
Quality / golangci-lint (push) Waiting to run
Quality / Build (push) Waiting to run
Quality / Build-1 (push) Waiting to run
Security / Go Vulnerability Check (push) Waiting to run
Security / Security Scanner (push) Waiting to run

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.
This commit is contained in:
Vito Castellano 2025-12-28 12:45:21 +01:00 committed by GitHub
commit c933f00d00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 512 additions and 34 deletions

View file

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

View file

@ -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() {

View file

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