feat: add Homebrew provider, implement NameNormalizer across all managers

- Add brew package for Homebrew support on macOS and Linux
- Implement NameNormalizer interface (NormalizeName, ParseArch) for all providers
- Add darwin platform detection with Homebrew as default
- Consolidate capabilities by removing separate *_linux.go/*_other.go files
- Update tests for new capability expectations
- Add comprehensive tests for AUR and brew providers
- Update README with capability matrix and modern Target API usage

💘 Generated with Crush

Assisted-by: AWS Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
2026-03-05 20:40:32 -05:00
parent 724ecc866e
commit 934c6610c5
60 changed files with 2554 additions and 967 deletions

View File

@@ -5,59 +5,68 @@ package aur
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/gogrlx/snack"
)
const aurGitBase = "https://aur.archlinux.org"
const aurRPC = "https://aur.archlinux.org/rpc/v5"
func available() bool {
for _, tool := range []string{"makepkg", "pacman"} {
if _, err := exec.LookPath(tool); err != nil {
return false
}
}
return true
_, err := exec.LookPath("makepkg")
return err == nil
}
// runPacman executes a pacman command and returns stdout.
func runPacman(ctx context.Context, args []string, sudo bool) (string, error) {
cmd := "pacman"
if sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
c := exec.CommandContext(ctx, cmd, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
// aurSearchResponse is the JSON response from the AUR RPC API.
type aurSearchResponse struct {
ResultCount int `json:"resultcount"`
Results []struct {
Name string `json:"Name"`
Version string `json:"Version"`
Description string `json:"Description"`
URL string `json:"URL"`
OutOfDate *int64 `json:"OutOfDate"`
Maintainer string `json:"Maintainer"`
Popularity float64 `json:"Popularity"`
} `json:"results"`
}
func aurQuery(ctx context.Context, queryType, arg string) (*aurSearchResponse, error) {
url := fmt.Sprintf("%s/%s/%s", aurRPC, queryType, arg)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") || strings.Contains(se, "requires root") {
return "", fmt.Errorf("aur: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("aur: %s: %w", strings.TrimSpace(se), err)
return nil, err
}
return stdout.String(), nil
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
var result aurSearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
return &result, nil
}
// install clones PKGBUILDs from the AUR, builds with makepkg, and installs with pacman.
func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var installed []snack.Package
var unchanged []string
for _, t := range pkgs {
// Check if already installed
if !o.Reinstall && !o.DryRun {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
@@ -69,19 +78,8 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
}
}
pkgFile, err := a.buildPackage(ctx, t)
if err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
}
if o.DryRun {
installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"})
continue
}
args := []string{"-U", "--noconfirm", pkgFile}
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
if err := installPkg(ctx, t.Name, o); err != nil {
return snack.InstallResult{}, err
}
v, _ := version(ctx, t.Name)
@@ -92,315 +90,92 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
Installed: true,
})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
// buildPackage clones the AUR git repo for a package and runs makepkg.
// Returns the path to the built .pkg.tar.zst file.
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error) {
// Determine build directory
buildDir := a.BuildDir
if buildDir == "" {
tmp, err := os.MkdirTemp("", "snack-aur-*")
if err != nil {
return "", fmt.Errorf("creating temp dir: %w", err)
}
buildDir = tmp
}
pkgDir := filepath.Join(buildDir, t.Name)
// Clone or update the PKGBUILD repo
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
return "", err
}
// Run makepkg
args := []string{"-s", "-f", "--noconfirm"}
args = append(args, a.MakepkgFlags...)
c := exec.CommandContext(ctx, "makepkg", args...)
c.Dir = pkgDir
var stderr bytes.Buffer
c.Stderr = &stderr
c.Stdout = &stderr // makepkg output goes to stderr anyway
if err := c.Run(); err != nil {
return "", fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err)
}
// Find the built package file
matches, err := filepath.Glob(filepath.Join(pkgDir, "*.pkg.tar*"))
if err != nil || len(matches) == 0 {
return "", fmt.Errorf("makepkg %s: no package file produced", t.Name)
}
return matches[len(matches)-1], nil
}
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.
func cloneOrPull(ctx context.Context, pkg, dir string) error {
repoURL := aurGitBase + "/" + pkg + ".git"
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
// Repo exists, pull latest
r, err := git.PlainOpen(dir)
if err != nil {
return fmt.Errorf("aur open %s: %w", pkg, err)
}
w, err := r.Worktree()
if err != nil {
return fmt.Errorf("aur worktree %s: %w", pkg, err)
}
if err := w.Pull(&git.PullOptions{}); err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("aur pull %s: %w", pkg, err)
}
return nil
}
// Clone fresh (depth 1)
_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: repoURL,
Depth: 1,
})
func installPkg(ctx context.Context, pkg string, opts snack.Options) error {
tmpDir, err := os.MkdirTemp("", "aur-"+pkg)
if err != nil {
if err == transport.ErrRepositoryNotFound {
return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound)
return fmt.Errorf("aur: create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
gitURL := fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg)
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", gitURL, tmpDir)
var stderr bytes.Buffer
cloneCmd.Stderr = &stderr
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("aur: git clone %s: %s: %w", pkg, stderr.String(), err)
}
makepkgArgs := []string{"-si", "--noconfirm"}
if opts.AssumeYes {
makepkgArgs = append(makepkgArgs, "--noconfirm")
}
makeCmd := exec.CommandContext(ctx, "makepkg", makepkgArgs...)
makeCmd.Dir = tmpDir
makeCmd.Stderr = &stderr
makeCmd.Stdout = os.Stdout
if err := makeCmd.Run(); err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") {
return fmt.Errorf("aur: %w", snack.ErrPermissionDenied)
}
return fmt.Errorf("aur clone %s: %w", pkg, err)
return fmt.Errorf("aur: makepkg %s: %s: %w", pkg, se, err)
}
return nil
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
o := snack.ApplyOptions(opts...)
var toRemove []snack.Target
var unchanged []string
for _, t := range pkgs {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.RemoveResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toRemove = append(toRemove, t)
}
}
if len(toRemove) > 0 {
args := append([]string{"-R", "--noconfirm"}, snack.TargetNames(toRemove)...)
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
return snack.RemoveResult{}, err
}
}
var removed []snack.Package
for _, t := range toRemove {
removed = append(removed, snack.Package{Name: t.Name})
}
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
return snack.RemoveResult{}, fmt.Errorf("aur: remove not supported, use pacman instead")
}
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...)
_, err := runPacman(ctx, args, o.Sudo)
return err
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return fmt.Errorf("aur: purge not supported, use pacman instead")
}
// upgradeAll rebuilds all installed foreign packages that have newer versions in the AUR.
func (a *AUR) upgradeAll(ctx context.Context, opts ...snack.Option) error {
upgrades, err := listUpgrades(ctx)
func upgrade(ctx context.Context, opts ...snack.Option) error {
aurPkgs, err := list(ctx)
if err != nil {
return err
}
if len(upgrades) == 0 {
return nil
}
targets := make([]snack.Target, len(upgrades))
for i, p := range upgrades {
targets[i] = snack.Target{Name: p.Name}
}
// Force reinstall since we're upgrading
allOpts := append([]snack.Option{snack.WithReinstall()}, opts...)
_, err = a.install(ctx, targets, allOpts...)
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
// pacman -Qm lists foreign (non-repo) packages, which are typically AUR
out, err := runPacman(ctx, []string{"-Qm"}, false)
if err != nil {
// exit status 1 means no foreign packages
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("aur list: %w", err)
}
return parsePackageList(out), nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("aur isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := runPacman(ctx, []string{"-Q", pkg}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("aur version: %w", err)
}
parts := strings.Fields(strings.TrimSpace(out))
if len(parts) < 2 {
return "", fmt.Errorf("aur version %s: unexpected output %q", pkg, out)
}
return parts[1], nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
p, err := rpcInfo(ctx, pkg)
if err != nil {
return "", err
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
// Get all installed foreign packages
installed, err := list(ctx)
if err != nil {
return nil, err
}
if len(installed) == 0 {
return nil, nil
}
// Batch-query the AUR for all of them
names := make([]string, len(installed))
for i, p := range installed {
names[i] = p.Name
}
aurInfo, err := rpcInfoMulti(ctx, names)
if err != nil {
return nil, err
}
// Compare versions
var upgrades []snack.Package
for _, inst := range installed {
aurPkg, ok := aurInfo[inst.Name]
if !ok {
continue // not in AUR (maybe from a custom repo)
}
cmp, err := versionCmp(ctx, inst.Version, aurPkg.Version)
for _, p := range aurPkgs {
result, err := aurQuery(ctx, "info", p.Name)
if err != nil {
continue // skip packages where vercmp fails
continue
}
if cmp < 0 {
upgrades = append(upgrades, snack.Package{
Name: inst.Name,
Version: aurPkg.Version,
Repository: "aur",
Installed: true,
})
if result.ResultCount == 0 {
continue
}
}
return upgrades, nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
inst, err := version(ctx, pkg)
if err != nil {
return false, err
}
latest, err := latestVersion(ctx, pkg)
if err != nil {
return false, err
}
cmp, err := versionCmp(ctx, inst, latest)
if err != nil {
return false, err
}
return cmp < 0, nil
}
func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
c := exec.CommandContext(ctx, "vercmp", ver1, ver2)
out, err := c.Output()
if err != nil {
return 0, fmt.Errorf("vercmp: %w", err)
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, fmt.Errorf("vercmp: unexpected output %q: %w", string(out), err)
}
switch {
case n < 0:
return -1, nil
case n > 0:
return 1, nil
default:
return 0, nil
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// Get orphans
orphans, err := runPacman(ctx, []string{"-Qdtq"}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil // no orphans
}
return fmt.Errorf("aur autoremove: %w", err)
}
orphans = strings.TrimSpace(orphans)
if orphans == "" {
return nil
}
pkgs := strings.Fields(orphans)
args := append([]string{"-Rns", "--noconfirm"}, pkgs...)
_, err = runPacman(ctx, args, o.Sudo)
return err
}
// cleanBuildDir removes all subdirectories in the build directory.
func (a *AUR) cleanBuildDir() error {
if a.BuildDir == "" {
return nil // temp dirs are cleaned automatically
}
entries, err := os.ReadDir(a.BuildDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("aur clean: %w", err)
}
for _, e := range entries {
if e.IsDir() {
if err := os.RemoveAll(filepath.Join(a.BuildDir, e.Name())); err != nil {
return fmt.Errorf("aur clean %s: %w", e.Name(), err)
if result.Results[0].Version != p.Version {
if _, err := install(ctx, []snack.Target{{Name: p.Name}}, opts...); err != nil {
return err
}
}
}
return nil
}
// parsePackageList parses "name version" lines from pacman -Q output.
func update(_ context.Context) error {
return nil
}
func list(ctx context.Context) ([]snack.Package, error) {
c := exec.CommandContext(ctx, "pacman", "-Qm")
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
// exit status 1 means no foreign packages
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil, nil
}
return nil, fmt.Errorf("aur list: %w", err)
}
return parsePackageList(stdout.String()), nil
}
func parsePackageList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
@@ -421,3 +196,198 @@ func parsePackageList(output string) []snack.Package {
}
return pkgs
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
result, err := aurQuery(ctx, "search", query)
if err != nil {
return nil, err
}
var pkgs []snack.Package
for _, r := range result.Results {
pkgs = append(pkgs, snack.Package{
Name: r.Name,
Version: r.Version,
Description: r.Description,
})
}
return pkgs, nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
result, err := aurQuery(ctx, "info", pkg)
if err != nil {
return nil, err
}
if result.ResultCount == 0 {
return nil, fmt.Errorf("aur info %s: %w", pkg, snack.ErrNotFound)
}
r := result.Results[0]
return &snack.Package{
Name: r.Name,
Version: r.Version,
Description: r.Description,
}, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("aur isInstalled: %w", err)
}
aurPkgs, err := list(ctx)
if err != nil {
return false, err
}
for _, p := range aurPkgs {
if p.Name == pkg {
return true, nil
}
}
return false, nil
}
func version(ctx context.Context, pkg string) (string, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("aur version: %w", err)
}
parts := strings.Fields(strings.TrimSpace(stdout.String()))
if len(parts) < 2 {
return "", fmt.Errorf("aur version %s: unexpected output", pkg)
}
return parts[1], nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
result, err := aurQuery(ctx, "info", pkg)
if err != nil {
return "", err
}
if result.ResultCount == 0 {
return "", fmt.Errorf("aur latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return result.Results[0].Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
aurPkgs, err := list(ctx)
if err != nil {
return nil, err
}
var upgrades []snack.Package
for _, p := range aurPkgs {
result, err := aurQuery(ctx, "info", p.Name)
if err != nil || result.ResultCount == 0 {
continue
}
if result.Results[0].Version != p.Version {
upgrades = append(upgrades, snack.Package{
Name: p.Name,
Version: result.Results[0].Version,
Repository: "aur",
Installed: true,
})
}
}
return upgrades, nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
installed, err := version(ctx, pkg)
if err != nil {
return false, err
}
latest, err := latestVersion(ctx, pkg)
if err != nil {
return false, err
}
cmp, err := versionCmp(ctx, installed, latest)
if err != nil {
return false, err
}
return cmp < 0, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
c := exec.Command("vercmp", ver1, ver2)
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
return 0, fmt.Errorf("aur versionCmp: %w", err)
}
result := strings.TrimSpace(stdout.String())
switch result {
case "-1":
return -1, nil
case "0":
return 0, nil
case "1":
return 1, nil
default:
return 0, fmt.Errorf("aur versionCmp: unexpected output %q", result)
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// Get orphans via pacman
c := exec.CommandContext(ctx, "pacman", "-Qdtq")
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
// exit status 1 means no orphans
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil
}
return fmt.Errorf("aur autoremove: %w", err)
}
orphans := strings.TrimSpace(stdout.String())
if orphans == "" {
return nil
}
args := []string{"-Rns", "--noconfirm"}
args = append(args, strings.Fields(orphans)...)
cmd := "pacman"
if o.Sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
removeCmd := exec.CommandContext(ctx, cmd, args...)
return removeCmd.Run()
}
func clean(_ context.Context) error {
// AUR builds in temp dirs, nothing persistent to clean
return nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
// For AUR, upgrading is just reinstalling from source
allOpts := append([]snack.Option{snack.WithReinstall()}, opts...)
return install(ctx, pkgs, allOpts...)
}
// getAURBuildDir returns the directory to use for AUR builds.
func getAURBuildDir() string {
if dir := os.Getenv("AURDEST"); dir != "" {
return dir
}
if cache := os.Getenv("XDG_CACHE_HOME"); cache != "" {
return filepath.Join(cache, "aur")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "aur")
}