bold-brew/internal/services/brew.go
Vito Castellano 6c80585431
chore: release version 2.0.0 - Cask support and XDG compliance (#30)
* feat: add leaves filter to show explicitly installed packages (#25)

Add new filter [L] to display only "leaf" packages - those installed
explicitly by the user and not as dependencies of other packages.

* refactor: Migrate to Podman with OCI Containerfile and enhanced Makefile (#26)

* refactor: migrate from Docker to Podman with OCI Containerfile

Replace Docker with Podman for better security and OCI compliance.
Switch from Dockerfile to standard Containerfile format.

* chore: upgrade Go from 1.24 to 1.25

Update Go version to 1.25 to support latest goreleaser v2 and benefit from improved performance and language features.

* refactor: migrate to Podman and enhance Makefile

Replace Docker with Podman and upgrade Makefile with help system and new developer-friendly targets.

* chore: upgrade to Go 1.25 and golangci-lint v2.5.0

Update Go to 1.25 and golangci-lint to v2.5.0 for better tooling support.

* feat: add security scanning with govulncheck and gosec (#27)

Add comprehensive security scanning to the project with vulnerability checks and static analysis tools.

* feat: Add complete Casks support with unified UI (#28)

* feat(cask): add backend support for Homebrew casks

Implement complete backend infrastructure for managing Homebrew casks alongside formulae, preparing for unified UI.

* feat(cask): add complete Homebrew casks support with unified UI

Implement full backend and UI support for managing Homebrew casks alongside formulae in a unified interface.

* fix(cask): parse cask analytics correctly

Fix cask analytics not being displayed (showing 0 for all casks).

* feat(cask): add complete Homebrew casks support with unified UI

Implement full backend and UI support for managing Homebrew casks alongside formulae in a unified interface.

* fix: create copy to avoid implicit memory aliasing

* feat: implement XDG Base Directory Specification with github.com/adrg/xdg (#29)

Implement XDG Base Directory Specification using the github.com/adrg/xdg package for robust cross-platform support.
2025-10-13 21:26:18 +02:00

593 lines
16 KiB
Go

package services
import (
"bbrew/internal/models"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"github.com/adrg/xdg"
"github.com/rivo/tview"
)
const FormulaeAPIURL = "https://formulae.brew.sh/api/formula.json"
const CaskAPIURL = "https://formulae.brew.sh/api/cask.json"
const AnalyticsAPIURL = "https://formulae.brew.sh/api/analytics/install-on-request/90d.json"
const CaskAnalyticsAPIURL = "https://formulae.brew.sh/api/analytics/cask-install/90d.json"
// getCacheDir - returns the cache directory following XDG Base Directory Specification.
func getCacheDir() string {
return filepath.Join(xdg.CacheHome, "bbrew")
}
type BrewServiceInterface interface {
GetPrefixPath() (path string)
GetFormulae() (formulae *[]models.Formula)
GetPackages() (packages *[]models.Package)
SetupData(forceDownload bool) (err error)
GetBrewVersion() (version string, err error)
UpdateHomebrew() error
UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error
UpdatePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error
RemovePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error
InstallPackage(info models.Package, app *tview.Application, outputView *tview.TextView) error
}
// BrewService provides methods to interact with Homebrew, including
// retrieving formulae, casks, and handling analytics.
type BrewService struct {
// Formula lists
all *[]models.Formula
installed *[]models.Formula
remote *[]models.Formula
analytics map[string]models.AnalyticsItem
// Cask lists
allCasks *[]models.Cask
installedCasks *[]models.Cask
remoteCasks *[]models.Cask
caskAnalytics map[string]models.AnalyticsItem
// Unified package list
allPackages *[]models.Package
brewVersion string
prefixPath string
}
// NewBrewService creates a new instance of BrewService with initialized package lists.
var NewBrewService = func() BrewServiceInterface {
return &BrewService{
all: new([]models.Formula),
installed: new([]models.Formula),
remote: new([]models.Formula),
allCasks: new([]models.Cask),
installedCasks: new([]models.Cask),
remoteCasks: new([]models.Cask),
allPackages: new([]models.Package),
}
}
// GetPrefixPath retrieves the Homebrew prefix path, caching it for future calls.
func (s *BrewService) GetPrefixPath() (path string) {
if s.prefixPath != "" {
return s.prefixPath
}
cmd := exec.Command("brew", "--prefix")
output, err := cmd.Output()
if err != nil {
s.prefixPath = "Unknown"
return
}
s.prefixPath = strings.TrimSpace(string(output))
return s.prefixPath
}
// GetFormulae retrieves all formulae, merging remote and installed packages,
func (s *BrewService) GetFormulae() (formulae *[]models.Formula) {
packageMap := make(map[string]models.Formula)
// Add REMOTE packages to the map if they don't already exist
for _, formula := range *s.remote {
if _, exists := packageMap[formula.Name]; !exists {
packageMap[formula.Name] = formula
}
}
// Add INSTALLED packages to the map
for _, formula := range *s.installed {
packageMap[formula.Name] = formula
}
*s.all = make([]models.Formula, 0, len(packageMap))
for _, formula := range packageMap {
// Merge analytics data if available
if a, exists := s.analytics[formula.Name]; exists && a.Number > 0 {
downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", ""))
formula.Analytics90dRank = a.Number
formula.Analytics90dDownloads = downloads
}
*s.all = append(*s.all, formula)
}
// Sort the list by name
sort.Slice(*s.all, func(i, j int) bool {
return (*s.all)[i].Name < (*s.all)[j].Name
})
return s.all
}
// GetPackages retrieves all packages (formulae + casks), merging remote and installed.
func (s *BrewService) GetPackages() (packages *[]models.Package) {
packageMap := make(map[string]models.Package)
// Add REMOTE formulae
for _, formula := range *s.remote {
if _, exists := packageMap[formula.Name]; !exists {
f := formula // Create a copy to avoid implicit memory aliasing
pkg := models.NewPackageFromFormula(&f)
// Merge analytics data
if a, exists := s.analytics[formula.Name]; exists && a.Number > 0 {
downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", ""))
pkg.Analytics90dRank = a.Number
pkg.Analytics90dDownloads = downloads
}
packageMap[formula.Name] = pkg
}
}
// Add INSTALLED formulae (override remote data)
for _, formula := range *s.installed {
f := formula // Create a copy to avoid implicit memory aliasing
pkg := models.NewPackageFromFormula(&f)
// Merge analytics data
if a, exists := s.analytics[formula.Name]; exists && a.Number > 0 {
downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", ""))
pkg.Analytics90dRank = a.Number
pkg.Analytics90dDownloads = downloads
}
packageMap[formula.Name] = pkg
}
// Add REMOTE casks
for _, cask := range *s.remoteCasks {
if _, exists := packageMap[cask.Token]; !exists {
c := cask // Create a copy to avoid implicit memory aliasing
pkg := models.NewPackageFromCask(&c)
// Merge analytics data
if a, exists := s.caskAnalytics[cask.Token]; exists && a.Number > 0 {
downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", ""))
pkg.Analytics90dRank = a.Number
pkg.Analytics90dDownloads = downloads
}
packageMap[cask.Token] = pkg
}
}
// Add INSTALLED casks (override remote data)
for _, cask := range *s.installedCasks {
c := cask // Create a copy to avoid implicit memory aliasing
pkg := models.NewPackageFromCask(&c)
// Merge analytics data
if a, exists := s.caskAnalytics[cask.Token]; exists && a.Number > 0 {
downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", ""))
pkg.Analytics90dRank = a.Number
pkg.Analytics90dDownloads = downloads
}
packageMap[cask.Token] = pkg
}
// Convert map to slice
*s.allPackages = make([]models.Package, 0, len(packageMap))
for _, pkg := range packageMap {
*s.allPackages = append(*s.allPackages, pkg)
}
// Sort by name
sort.Slice(*s.allPackages, func(i, j int) bool {
return (*s.allPackages)[i].Name < (*s.allPackages)[j].Name
})
return s.allPackages
}
// SetupData initializes the BrewService by loading installed packages, remote formulae, casks, and analytics data.
func (s *BrewService) SetupData(forceDownload bool) (err error) {
// Load formulae
if err = s.loadInstalled(); err != nil {
return fmt.Errorf("failed to load installed formulae: %w", err)
}
if err = s.loadRemote(forceDownload); err != nil {
return fmt.Errorf("failed to load remote formulae: %w", err)
}
if err = s.loadAnalytics(); err != nil {
return fmt.Errorf("failed to load formulae analytics: %w", err)
}
// Load casks
if err = s.loadInstalledCasks(); err != nil {
return fmt.Errorf("failed to load installed casks: %w", err)
}
if err = s.loadRemoteCasks(forceDownload); err != nil {
return fmt.Errorf("failed to load remote casks: %w", err)
}
if err = s.loadCaskAnalytics(); err != nil {
return fmt.Errorf("failed to load cask analytics: %w", err)
}
return nil
}
// loadInstalled retrieves the list of installed Homebrew formulae and updates their local paths.
func (s *BrewService) loadInstalled() (err error) {
cmd := exec.Command("brew", "info", "--json=v1", "--installed")
output, err := cmd.Output()
if err != nil {
return err
}
*s.installed = make([]models.Formula, 0)
err = json.Unmarshal(output, &s.installed)
if err != nil {
return err
}
// Mark all installed Packages as locally installed and set LocalPath
prefix := s.GetPrefixPath()
for i := range *s.installed {
(*s.installed)[i].LocallyInstalled = true
(*s.installed)[i].LocalPath = filepath.Join(prefix, "Cellar", (*s.installed)[i].Name)
}
return nil
}
// loadInstalledCasks retrieves the list of installed Homebrew casks.
func (s *BrewService) loadInstalledCasks() (err error) {
// Get list of installed cask names
listCmd := exec.Command("brew", "list", "--cask")
listOutput, err := listCmd.Output()
if err != nil {
// If no casks are installed, brew returns error - ignore it
*s.installedCasks = make([]models.Cask, 0)
return nil
}
// Parse cask names (one per line)
caskNames := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
if len(caskNames) == 0 || (len(caskNames) == 1 && caskNames[0] == "") {
*s.installedCasks = make([]models.Cask, 0)
return nil
}
// Get info for each installed cask using --json=v2 (v2 required for casks)
args := append([]string{"info", "--json=v2", "--cask"}, caskNames...)
infoCmd := exec.Command("brew", args...)
infoOutput, err := infoCmd.Output()
if err != nil {
*s.installedCasks = make([]models.Cask, 0)
return nil
}
// Parse JSON response (v2 returns object with "formulae" and "casks" keys)
// We only need the "casks" array since we specified --cask flag
var response struct {
Casks []models.Cask `json:"casks"`
}
err = json.Unmarshal(infoOutput, &response)
if err != nil {
return err
}
*s.installedCasks = response.Casks
// Mark all installed casks as locally installed
for i := range *s.installedCasks {
(*s.installedCasks)[i].LocallyInstalled = true
(*s.installedCasks)[i].IsCask = true
}
return nil
}
// loadRemote retrieves the list of remote Homebrew formulae from the API and caches them locally.
func (s *BrewService) loadRemote(forceDownload bool) (err error) {
cacheDir := getCacheDir()
formulaFile := filepath.Join(cacheDir, "formula.json")
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
if err := os.MkdirAll(cacheDir, 0750); err != nil {
return err
}
}
// Check if we should use the cached file
if !forceDownload {
if _, err := os.Stat(formulaFile); err == nil {
// #nosec G304 -- formulaFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
data, err := os.ReadFile(formulaFile)
if err == nil {
*s.remote = make([]models.Formula, 0)
if err := json.Unmarshal(data, &s.remote); err == nil {
return nil
}
}
}
}
resp, err := http.Get(FormulaeAPIURL)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
*s.remote = make([]models.Formula, 0)
err = json.Unmarshal(body, s.remote)
if err != nil {
return err
}
// Cache the remote formulae data
_ = os.WriteFile(formulaFile, body, 0600)
return nil
}
// loadRemoteCasks retrieves the list of remote Homebrew casks from the API and caches them locally.
func (s *BrewService) loadRemoteCasks(forceDownload bool) (err error) {
cacheDir := getCacheDir()
caskFile := filepath.Join(cacheDir, "cask.json")
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
if err := os.MkdirAll(cacheDir, 0750); err != nil {
return err
}
}
// Check if we should use the cached file
if !forceDownload {
if _, err := os.Stat(caskFile); err == nil {
// #nosec G304 -- caskFile path is safely constructed from UserHomeDir and sanitized with filepath.Join
data, err := os.ReadFile(caskFile)
if err == nil {
*s.remoteCasks = make([]models.Cask, 0)
if err := json.Unmarshal(data, &s.remoteCasks); err == nil {
return nil
}
}
}
}
resp, err := http.Get(CaskAPIURL)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
*s.remoteCasks = make([]models.Cask, 0)
err = json.Unmarshal(body, s.remoteCasks)
if err != nil {
return err
}
// Cache the remote cask data
_ = os.WriteFile(caskFile, body, 0600)
return nil
}
// loadAnalytics retrieves the analytics data for Homebrew formulae from the API.
func (s *BrewService) loadAnalytics() (err error) {
resp, err := http.Get(AnalyticsAPIURL)
if err != nil {
return err
}
defer resp.Body.Close()
analytics := models.Analytics{}
err = json.NewDecoder(resp.Body).Decode(&analytics)
if err != nil {
return err
}
analyticsByFormula := map[string]models.AnalyticsItem{}
for _, f := range analytics.Items {
analyticsByFormula[f.Formula] = f
}
s.analytics = analyticsByFormula
return nil
}
// loadCaskAnalytics retrieves the analytics data for Homebrew casks from the API.
func (s *BrewService) loadCaskAnalytics() (err error) {
resp, err := http.Get(CaskAnalyticsAPIURL)
if err != nil {
return err
}
defer resp.Body.Close()
analytics := models.Analytics{}
err = json.NewDecoder(resp.Body).Decode(&analytics)
if err != nil {
return err
}
analyticsByCask := map[string]models.AnalyticsItem{}
for _, c := range analytics.Items {
// Cask analytics use the "cask" field instead of "formula"
caskName := c.Cask
if caskName != "" {
analyticsByCask[caskName] = c
}
}
s.caskAnalytics = analyticsByCask
return nil
}
// GetBrewVersion retrieves the version of Homebrew installed on the system, caching it for future calls.
func (s *BrewService) GetBrewVersion() (version string, err error) {
if s.brewVersion != "" {
return s.brewVersion, nil
}
cmd := exec.Command("brew", "--version")
output, err := cmd.Output()
if err != nil {
return "", err
}
s.brewVersion = strings.TrimSpace(string(output))
return s.brewVersion, nil
}
// UpdateHomebrew updates the Homebrew package manager by running the `brew update` command.
func (s *BrewService) UpdateHomebrew() error {
cmd := exec.Command("brew", "update")
if err := cmd.Run(); err != nil {
return err
}
return nil
}
func (s *BrewService) UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error {
cmd := exec.Command("brew", "upgrade") // #nosec G204
return s.executeCommand(app, cmd, outputView)
}
func (s *BrewService) UpdatePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error {
var cmd *exec.Cmd
if info.Type == models.PackageTypeCask {
cmd = exec.Command("brew", "upgrade", "--cask", info.Name) // #nosec G204
} else {
cmd = exec.Command("brew", "upgrade", info.Name) // #nosec G204
}
return s.executeCommand(app, cmd, outputView)
}
func (s *BrewService) RemovePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error {
var cmd *exec.Cmd
if info.Type == models.PackageTypeCask {
cmd = exec.Command("brew", "uninstall", "--cask", info.Name) // #nosec G204
} else {
cmd = exec.Command("brew", "uninstall", info.Name) // #nosec G204
}
return s.executeCommand(app, cmd, outputView)
}
func (s *BrewService) InstallPackage(info models.Package, app *tview.Application, outputView *tview.TextView) error {
var cmd *exec.Cmd
if info.Type == models.PackageTypeCask {
cmd = exec.Command("brew", "install", "--cask", info.Name) // #nosec G204
} else {
cmd = exec.Command("brew", "install", info.Name) // #nosec G204
}
return s.executeCommand(app, cmd, outputView)
}
// executeCommand runs a command and captures its output, updating the provided TextView in the application.
func (s *BrewService) executeCommand(
app *tview.Application,
cmd *exec.Cmd,
outputView *tview.TextView,
) error {
stdoutPipe, stdoutWriter := io.Pipe()
stderrPipe, stderrWriter := io.Pipe()
cmd.Stdout = stdoutWriter
cmd.Stderr = stderrWriter
if err := cmd.Start(); err != nil {
return err
}
// Add a WaitGroup to wait for all goroutines to finish
var wg sync.WaitGroup
wg.Add(3)
// 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
}()
// Stdout handler
go func() {
defer wg.Done()
defer stdoutPipe.Close()
buf := make([]byte, 1024)
for {
n, err := stdoutPipe.Read(buf)
if n > 0 {
output := make([]byte, n)
copy(output, buf[:n])
app.QueueUpdateDraw(func() {
_, _ = outputView.Write(output) // #nosec G104
outputView.ScrollToEnd()
})
}
if err != nil {
if err != io.EOF {
app.QueueUpdateDraw(func() {
fmt.Fprintf(outputView, "\nError: %v\n", err)
})
}
break
}
}
}()
// Stderr handler
go func() {
defer wg.Done()
defer stderrPipe.Close()
buf := make([]byte, 1024)
for {
n, err := stderrPipe.Read(buf)
if n > 0 {
output := make([]byte, n)
copy(output, buf[:n])
app.QueueUpdateDraw(func() {
_, _ = outputView.Write(output) // #nosec G104
outputView.ScrollToEnd()
})
}
if err != nil {
if err != io.EOF {
app.QueueUpdateDraw(func() {
fmt.Fprintf(outputView, "\nError: %v\n", err)
})
}
break
}
}
}()
wg.Wait()
return nil
}