go-package-manager/brew.go
2026-07-01 17:47:49 -05:00

273 lines
8.2 KiB
Go

package pkgmgr
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// brewPaths are the common locations Homebrew installs its command, checked
// when brew is not already on PATH.
var brewPaths = []string{
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
"/home/linuxbrew/.linuxbrew/bin/brew",
}
// Brew is the Homebrew manager. Homebrew refuses to run as root, so its command
// is never sudo-wrapped; the embedded dropPrivilege runs it unprivileged and
// drops to a user via `sudo -u` when the process is root.
type Brew struct {
baseManager
dropPrivilege
}
// Name is the package manager's name.
func (p *Brew) Name() string {
return "brew"
}
// Format is the package format the manager installs.
func (p *Brew) Format() string {
return "bottle"
}
// Path is the resolved path to the brew command, or "" if missing.
func (p *Brew) Path() string {
if bin, err := exec.LookPath("brew"); err == nil {
return bin
}
for _, candidate := range brewPaths {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
// exec runs brew with the given args, failing if brew cannot be found. brew is
// run unprivileged since Homebrew must not run as root.
func (p *Brew) exec(ctx context.Context, args ...string) error {
bin := p.Path()
if bin == "" {
return fmt.Errorf("unable to find brew")
}
return p.runUnprivileged(ctx, bin, p.dropTarget(), args...)
}
// AddRepo adds a repo by tapping it. config, when set, is the tap's clone URL.
func (p *Brew) AddRepo(name, config string) error {
args := []string{"tap", name}
if config != "" {
args = append(args, config)
}
return p.exec(context.Background(), args...)
}
// AddRepoURL adds a repo by tapping name from repoURL.
func (p *Brew) AddRepoURL(ctx context.Context, name, repoURL string) error {
return p.exec(ctx, "tap", name, repoURL)
}
// RemoveRepo removes a repo by untapping it.
func (p *Brew) RemoveRepo(name string) error {
return p.exec(context.Background(), "untap", name)
}
// GetRepo returns tap information for name, or "" if it is not tapped.
func (p *Brew) GetRepo(name string) string {
bin := p.Path()
if bin == "" {
return ""
}
// Output captures stdout itself, so this bypasses the configured writers.
out, err := exec.CommandContext(context.Background(), bin, "tap-info", name).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// ListRepos returns every tapped repo mapped name->tap-info. The value for each
// name matches what GetRepo returns for it. brew is run unprivileged since
// Homebrew must not run as root.
func (p *Brew) ListRepos(ctx context.Context, args []string) (map[string]string, error) {
bin := p.Path()
if bin == "" {
return nil, fmt.Errorf("unable to find brew")
}
cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), joinArgs(args, "tap")...)
if err != nil {
return nil, err
}
// Capture stdout directly; the listing is parsed rather than streamed.
cmd.Stdout = nil
var buf bytes.Buffer
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
return nil, err
}
out := make(map[string]string)
scanner := bufio.NewScanner(&buf)
for scanner.Scan() {
name := strings.TrimSpace(scanner.Text())
if name == "" {
continue
}
out[name] = p.GetRepo(name)
}
return out, scanner.Err()
}
// AddRepoKey is a no-op: Homebrew verifies downloads itself and has no
// user-managed signing keyring.
func (p *Brew) AddRepoKey(ctx context.Context, key string) error {
return nil
}
// AddRepoKeyFile is a no-op: Homebrew manages verification itself.
func (p *Brew) AddRepoKeyFile(ctx context.Context, keyFile string) error {
return nil
}
// AddRepoKeyURL is a no-op: Homebrew manages verification itself.
func (p *Brew) AddRepoKeyURL(ctx context.Context, keyURL string) error {
return nil
}
// Sync updates repository metadata.
func (p *Brew) Sync(ctx context.Context, args []string) error {
return p.exec(ctx, joinArgs(args, "update")...)
}
// Install installs packages from the repositories.
func (p *Brew) Install(ctx context.Context, args []string, packages ...string) error {
return p.exec(ctx, append(joinArgs(args, "install"), packages...)...)
}
// Remove removes packages.
func (p *Brew) Remove(ctx context.Context, args []string, packages ...string) error {
return p.exec(ctx, append(joinArgs(args, "uninstall"), packages...)...)
}
// Upgrade upgrades the named packages.
func (p *Brew) Upgrade(ctx context.Context, args []string, packages ...string) error {
return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...)
}
// InstallFile installs a package from a local formula or cask file.
func (p *Brew) InstallFile(ctx context.Context, args []string, packages ...string) error {
return p.Install(ctx, args, packages...)
}
// UpgradeAll upgrades all packages with available updates.
func (p *Brew) UpgradeAll(ctx context.Context, args []string) error {
return p.exec(ctx, joinArgs(args, "upgrade")...)
}
// Clean removes stale downloads and old installed versions. brew is run
// unprivileged since Homebrew must not run as root.
func (p *Brew) Clean(ctx context.Context, args []string) error {
return p.exec(ctx, joinArgs(args, "cleanup")...)
}
// ListUpgradable returns packages with an available update mapped to the
// candidate version. brew is run unprivileged since Homebrew must not run as
// root.
func (p *Brew) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) {
bin := p.Path()
if bin == "" {
return nil, fmt.Errorf("unable to find brew")
}
// `brew outdated --verbose` prints "name (oldver) < newver" per package.
args = joinArgs(args, "outdated", "--verbose")
cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), args...)
if err != nil {
return nil, err
}
return runParseList(cmd, parseBrewUpgradable)
}
// parseBrewUpgradable parses brew's "name (oldver) < newver" rows into a
// name->candidate-version map.
func parseBrewUpgradable(r io.Reader) (map[string]string, error) {
out := make(map[string]string)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
// Rows are "name (oldver) < newver"; the candidate is the final field.
if len(fields) < 4 || fields[len(fields)-2] != "<" {
continue
}
out[fields[0]] = fields[len(fields)-1]
}
return out, scanner.Err()
}
// Search searches the repositories for packages matching query. brew is run
// unprivileged since Homebrew must not run as root.
func (p *Brew) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) {
bin := p.Path()
if bin == "" {
return nil, fmt.Errorf("unable to find brew")
}
cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), joinArgs(args, "search", query)...)
if err != nil {
return nil, err
}
return runSearch(cmd, parseBrewSearch)
}
// parseBrewSearch parses brew's plain list of matching names into search
// results, skipping the "==> Formulae" / "==> Casks" section headers. brew
// search reports neither a version nor a summary.
func parseBrewSearch(r io.Reader) ([]SearchResult, error) {
var out []SearchResult
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "==>") {
continue
}
out = append(out, SearchResult{Name: line})
}
return out, scanner.Err()
}
// Info returns detailed information about the named packages. brew is run
// unprivileged since Homebrew must not run as root.
func (p *Brew) Info(ctx context.Context, args []string, packages ...string) (string, error) {
bin := p.Path()
if bin == "" {
return "", fmt.Errorf("unable to find brew")
}
cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), append(joinArgs(args, "info"), packages...)...)
if err != nil {
return "", err
}
return runCapture(cmd)
}
// ListInstalled returns installed packages mapped to their version.
func (p *Brew) ListInstalled(ctx context.Context, args []string) (map[string]string, error) {
bin := p.Path()
if bin == "" {
return nil, fmt.Errorf("unable to find brew")
}
// `brew list --versions` prints "name version [version...]" per line. brew is
// run unprivileged since Homebrew must not run as root.
args = joinArgs(args, "list", "--versions")
cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), args...)
if err != nil {
return nil, err
}
return runVersionList(cmd, " ", false)
}