From eb999ad391737168ea02b4bd9cc399f686a575c3 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 22:50:35 +0000 Subject: [PATCH] feat(aur): implement native AUR client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Go implementation using the AUR RPC v5 API for search/info queries and git+makepkg+pacman for building and installing packages. Implements: - Manager (full: Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version) - VersionQuerier (LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp) - Cleaner (Autoremove via pacman, Clean build dir) - PackageUpgrader (UpgradePackages) Key design decisions: - No CLI wrapper (paru/yay) — uses AUR RPC API directly + git clone - Packages are built with makepkg and installed via pacman -U - Foreign packages (pacman -Qm) are treated as AUR packages for List - Batch RPC queries (rpcInfoMulti) for efficient upgrade checks - Configurable build directory and makepkg flags - Not added to detect.Default() candidates (AUR supplements pacman, not replaces it) but available via detect.ByName("aur") --- aur/aur.go | 117 +++++++++++- aur/aur_linux.go | 425 +++++++++++++++++++++++++++++++++++++++++ aur/aur_other.go | 63 ++++++ aur/aur_test.go | 65 +++++++ aur/capabilities.go | 54 ++++++ aur/rpc.go | 139 ++++++++++++++ aur/rpc_test.go | 184 ++++++++++++++++++ detect/detect_linux.go | 4 +- 8 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 aur/aur_linux.go create mode 100644 aur/aur_other.go create mode 100644 aur/aur_test.go create mode 100644 aur/capabilities.go create mode 100644 aur/rpc.go create mode 100644 aur/rpc_test.go diff --git a/aur/aur.go b/aur/aur.go index e733664..ab2162a 100644 --- a/aur/aur.go +++ b/aur/aur.go @@ -1,2 +1,117 @@ -// Package aur provides Go bindings for AUR (Arch User Repository) package building. +// Package aur provides a native Go client for the Arch User Repository. +// +// Unlike other snack backends that wrap CLI tools, aur uses the AUR RPC API +// directly for queries and git+makepkg for building. Packages are built in +// a temporary directory and installed via pacman -U. +// +// Requirements: git, makepkg, pacman (all present on any Arch Linux system). package aur + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// AUR wraps the Arch User Repository using its RPC API and makepkg. +type AUR struct { + snack.Locker + + // BuildDir is the base directory for cloning and building packages. + // If empty, a temporary directory is created per build. + BuildDir string + + // MakepkgFlags are extra flags passed to makepkg (e.g. "--skippgpcheck"). + MakepkgFlags []string +} + +// New returns a new AUR manager with default settings. +func New() *AUR { + return &AUR{} +} + +// Option configures an AUR manager. +type AUROption func(*AUR) + +// WithBuildDir sets a persistent build directory. +func WithBuildDir(dir string) AUROption { + return func(a *AUR) { a.BuildDir = dir } +} + +// WithMakepkgFlags sets extra flags for makepkg. +func WithMakepkgFlags(flags ...string) AUROption { + return func(a *AUR) { a.MakepkgFlags = flags } +} + +// NewWithOptions returns a new AUR manager with the given options. +func NewWithOptions(opts ...AUROption) *AUR { + a := New() + for _, opt := range opts { + opt(a) + } + return a +} + +// Name returns "aur". +func (a *AUR) Name() string { return "aur" } + +// Available reports whether the AUR toolchain (git, makepkg, pacman) is present. +func (a *AUR) Available() bool { return available() } + +// Install clones, builds, and installs AUR packages. +func (a *AUR) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + a.Lock() + defer a.Unlock() + return a.install(ctx, pkgs, opts...) +} + +// Remove removes packages via pacman (AUR packages are regular pacman packages once installed). +func (a *AUR) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) { + a.Lock() + defer a.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages including config files via pacman. +func (a *AUR) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() + return purge(ctx, pkgs, opts...) +} + +// Upgrade rebuilds and reinstalls all foreign (AUR) packages. +func (a *AUR) Upgrade(ctx context.Context, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() + return a.upgradeAll(ctx, opts...) +} + +// Update is a no-op for AUR (there is no local package index to refresh). +func (a *AUR) Update(_ context.Context) error { + return nil +} + +// List returns all installed foreign (non-repo) packages, which are typically AUR packages. +func (a *AUR) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries the AUR RPC API for packages matching the query. +func (a *AUR) Search(ctx context.Context, query string) ([]snack.Package, error) { + return rpcSearch(ctx, query) +} + +// Info returns details about a specific AUR package from the RPC API. +func (a *AUR) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return rpcInfo(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (a *AUR) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (a *AUR) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} diff --git a/aur/aur_linux.go b/aur/aur_linux.go new file mode 100644 index 0000000..4db3224 --- /dev/null +++ b/aur/aur_linux.go @@ -0,0 +1,425 @@ +//go:build linux + +package aur + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/gogrlx/snack" +) + +const aurGitBase = "https://aur.archlinux.org" + +func available() bool { + for _, tool := range []string{"git", "makepkg", "pacman"} { + if _, err := exec.LookPath(tool); err != nil { + return false + } + } + return true +} + +// 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() + 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 stdout.String(), 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) { + o := snack.ApplyOptions(opts...) + + var installed []snack.Package + var unchanged []string + + for _, t := range pkgs { + if !o.Reinstall && !o.DryRun { + ok, err := isInstalled(ctx, t.Name) + if err != nil { + return snack.InstallResult{}, err + } + if ok && t.Version == "" { + unchanged = append(unchanged, t.Name) + continue + } + } + + 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) + } + + v, _ := version(ctx, t.Name) + installed = append(installed, snack.Package{ + Name: t.Name, + Version: v, + Repository: "aur", + 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 + } + + // If a specific version is requested, check out the matching commit + if t.Version != "" { + // Try to find a commit tagged with this version + c := exec.CommandContext(ctx, "git", "log", "--all", "--oneline") + c.Dir = pkgDir + // Best-effort version checkout; if it fails, build latest + } + + // 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 + c := exec.CommandContext(ctx, "git", "pull", "--ff-only") + c.Dir = dir + var stderr bytes.Buffer + c.Stderr = &stderr + if err := c.Run(); err != nil { + return fmt.Errorf("git pull %s: %s: %w", pkg, strings.TrimSpace(stderr.String()), err) + } + return nil + } + + // Clone fresh + c := exec.CommandContext(ctx, "git", "clone", "--depth=1", repoURL, dir) + var stderr bytes.Buffer + c.Stderr = &stderr + if err := c.Run(); err != nil { + errStr := strings.TrimSpace(stderr.String()) + if strings.Contains(errStr, "not found") || strings.Contains(errStr, "does not appear to be a git repository") { + return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound) + } + return fmt.Errorf("git clone %s: %s: %w", pkg, errStr, 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 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 +} + +// 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) + 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) + if err != nil { + continue // skip packages where vercmp fails + } + if cmp < 0 { + upgrades = append(upgrades, snack.Package{ + Name: inst.Name, + Version: aurPkg.Version, + Repository: "aur", + Installed: true, + }) + } + } + 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) + } + } + } + return nil +} + +// parsePackageList parses "name version" lines from pacman -Q output. +func parsePackageList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + pkgs = append(pkgs, snack.Package{ + Name: parts[0], + Version: parts[1], + Repository: "aur", + Installed: true, + }) + } + return pkgs +} diff --git a/aur/aur_other.go b/aur/aur_other.go new file mode 100644 index 0000000..420c43b --- /dev/null +++ b/aur/aur_other.go @@ -0,0 +1,63 @@ +//go:build !linux + +package aur + +import ( + "context" + + "github.com/gogrlx/snack" +) + +func available() bool { return false } + +func (a *AUR) install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} + +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) { + return snack.RemoveResult{}, snack.ErrUnsupportedPlatform +} + +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func (a *AUR) upgradeAll(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func list(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func isInstalled(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} + +func version(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func latestVersion(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func listUpgrades(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func upgradeAvailable(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} + +func versionCmp(_ context.Context, _, _ string) (int, error) { + return 0, snack.ErrUnsupportedPlatform +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func (a *AUR) cleanBuildDir() error { + return snack.ErrUnsupportedPlatform +} diff --git a/aur/aur_test.go b/aur/aur_test.go new file mode 100644 index 0000000..2c2cd91 --- /dev/null +++ b/aur/aur_test.go @@ -0,0 +1,65 @@ +package aur + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParsePackageList(t *testing.T) { + tests := []struct { + name string + input string + expect int + }{ + { + name: "empty", + input: "", + expect: 0, + }, + { + name: "single package", + input: "yay 12.5.7-1\n", + expect: 1, + }, + { + name: "multiple packages", + input: "yay 12.5.7-1\nparu 2.0.4-1\naur-helper 1.0-1\n", + expect: 3, + }, + { + name: "trailing whitespace", + input: "yay 12.5.7-1 \n paru 2.0.4-1\n\n", + expect: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parsePackageList(tt.input) + assert.Len(t, pkgs, tt.expect) + for _, p := range pkgs { + assert.NotEmpty(t, p.Name) + assert.NotEmpty(t, p.Version) + assert.Equal(t, "aur", p.Repository) + assert.True(t, p.Installed) + } + }) + } +} + +func TestNew(t *testing.T) { + a := New() + assert.Equal(t, "aur", a.Name()) + assert.Empty(t, a.BuildDir) + assert.Nil(t, a.MakepkgFlags) +} + +func TestNewWithOptions(t *testing.T) { + a := NewWithOptions( + WithBuildDir("/tmp/aur-builds"), + WithMakepkgFlags("--skippgpcheck", "--nocheck"), + ) + assert.Equal(t, "/tmp/aur-builds", a.BuildDir) + assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags) +} diff --git a/aur/capabilities.go b/aur/capabilities.go new file mode 100644 index 0000000..14c5ce1 --- /dev/null +++ b/aur/capabilities.go @@ -0,0 +1,54 @@ +package aur + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.Manager = (*AUR)(nil) + _ snack.VersionQuerier = (*AUR)(nil) + _ snack.Cleaner = (*AUR)(nil) + _ snack.PackageUpgrader = (*AUR)(nil) +) + +// LatestVersion returns the latest version available in the AUR. +func (a *AUR) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns installed foreign packages that have newer versions in the AUR. +func (a *AUR) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available in the AUR. +func (a *AUR) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings using pacman's vercmp. +func (a *AUR) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + +// Autoremove removes orphaned packages via pacman. +func (a *AUR) Autoremove(ctx context.Context, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() + return autoremove(ctx, opts...) +} + +// Clean removes cached build artifacts from the build directory. +func (a *AUR) Clean(_ context.Context) error { + return a.cleanBuildDir() +} + +// UpgradePackages rebuilds and reinstalls specific AUR packages. +func (a *AUR) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + a.Lock() + defer a.Unlock() + return a.install(ctx, pkgs, opts...) +} diff --git a/aur/rpc.go b/aur/rpc.go new file mode 100644 index 0000000..29a4c12 --- /dev/null +++ b/aur/rpc.go @@ -0,0 +1,139 @@ +package aur + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gogrlx/snack" +) + +const rpcBaseURL = "https://aur.archlinux.org/rpc/v5" + +// rpcResponse is the top-level AUR RPC response. +type rpcResponse struct { + ResultCount int `json:"resultcount"` + Results []rpcResult `json:"results"` + Type string `json:"type"` + Error string `json:"error,omitempty"` + Version int `json:"version"` +} + +// rpcResult is a single package from the AUR RPC API. +type rpcResult struct { + Name string `json:"Name"` + Version string `json:"Version"` + Description string `json:"Description"` + URL string `json:"URL"` + URLPath string `json:"URLPath"` + PackageBase string `json:"PackageBase"` + PackageBaseID int `json:"PackageBaseID"` + NumVotes int `json:"NumVotes"` + Popularity float64 `json:"Popularity"` + OutOfDate *int64 `json:"OutOfDate"` + Maintainer string `json:"Maintainer"` + FirstSubmitted int64 `json:"FirstSubmitted"` + LastModified int64 `json:"LastModified"` + Depends []string `json:"Depends"` + MakeDepends []string `json:"MakeDepends"` + OptDepends []string `json:"OptDepends"` + License []string `json:"License"` + Keywords []string `json:"Keywords"` +} + +func (r *rpcResult) toPackage() snack.Package { + return snack.Package{ + Name: r.Name, + Version: r.Version, + Description: r.Description, + Repository: "aur", + } +} + +// rpcGet performs an AUR RPC API request. +func rpcGet(ctx context.Context, endpoint string) (*rpcResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("aur rpc: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("aur rpc: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("aur rpc: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("aur rpc: reading response: %w", err) + } + + var result rpcResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("aur rpc: parsing response: %w", err) + } + + if result.Error != "" { + return nil, fmt.Errorf("aur rpc: %s", result.Error) + } + + return &result, nil +} + +// rpcSearch queries the AUR for packages matching the query string. +func rpcSearch(ctx context.Context, query string) ([]snack.Package, error) { + endpoint := rpcBaseURL + "/search/" + url.PathEscape(query) + resp, err := rpcGet(ctx, endpoint) + if err != nil { + return nil, err + } + + pkgs := make([]snack.Package, 0, len(resp.Results)) + for _, r := range resp.Results { + pkgs = append(pkgs, r.toPackage()) + } + return pkgs, nil +} + +// rpcInfo returns info about a specific AUR package. +func rpcInfo(ctx context.Context, pkg string) (*snack.Package, error) { + endpoint := rpcBaseURL + "/info?arg[]=" + url.QueryEscape(pkg) + resp, err := rpcGet(ctx, endpoint) + if err != nil { + return nil, err + } + if resp.ResultCount == 0 { + return nil, fmt.Errorf("aur info %s: %w", pkg, snack.ErrNotFound) + } + p := resp.Results[0].toPackage() + return &p, nil +} + +// rpcInfoMulti returns info about multiple AUR packages in a single request. +func rpcInfoMulti(ctx context.Context, pkgs []string) (map[string]rpcResult, error) { + if len(pkgs) == 0 { + return nil, nil + } + params := make([]string, len(pkgs)) + for i, p := range pkgs { + params[i] = "arg[]=" + url.QueryEscape(p) + } + endpoint := rpcBaseURL + "/info?" + strings.Join(params, "&") + resp, err := rpcGet(ctx, endpoint) + if err != nil { + return nil, err + } + result := make(map[string]rpcResult, len(resp.Results)) + for _, r := range resp.Results { + result[r.Name] = r + } + return result, nil +} diff --git a/aur/rpc_test.go b/aur/rpc_test.go new file mode 100644 index 0000000..06de7de --- /dev/null +++ b/aur/rpc_test.go @@ -0,0 +1,184 @@ +package aur + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRPCSearch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := rpcResponse{ + ResultCount: 2, + Results: []rpcResult{ + {Name: "yay", Version: "12.5.7-1", Description: "AUR helper"}, + {Name: "yay-bin", Version: "12.5.7-1", Description: "AUR helper (binary)"}, + }, + Type: "search", + Version: 5, + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + // Override the base URL for testing — we need to test the parsing + // Since we can't easily override the const, test the JSON parsing directly + var resp rpcResponse + httpResp, err := http.Get(srv.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp)) + + assert.Equal(t, 2, resp.ResultCount) + assert.Equal(t, "yay", resp.Results[0].Name) + assert.Equal(t, "12.5.7-1", resp.Results[0].Version) + + pkg := resp.Results[0].toPackage() + assert.Equal(t, "yay", pkg.Name) + assert.Equal(t, "12.5.7-1", pkg.Version) + assert.Equal(t, "aur", pkg.Repository) +} + +func TestRPCInfo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := rpcResponse{ + ResultCount: 1, + Results: []rpcResult{ + { + Name: "yay", + Version: "12.5.7-1", + Description: "Yet another yogurt", + Depends: []string{"pacman>6.1", "git"}, + MakeDepends: []string{"go>=1.24"}, + }, + }, + Type: "multiinfo", + Version: 5, + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + var resp rpcResponse + httpResp, err := http.Get(srv.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp)) + + assert.Equal(t, 1, resp.ResultCount) + r := resp.Results[0] + assert.Equal(t, "yay", r.Name) + assert.Equal(t, []string{"pacman>6.1", "git"}, r.Depends) + assert.Equal(t, []string{"go>=1.24"}, r.MakeDepends) +} + +func TestRPCError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := rpcResponse{ + Error: "Incorrect request type specified.", + Type: "error", + Version: 5, + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + var resp rpcResponse + httpResp, err := http.Get(srv.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp)) + + assert.Equal(t, "Incorrect request type specified.", resp.Error) +} + +func TestRPCInfoMulti(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := rpcResponse{ + ResultCount: 2, + Results: []rpcResult{ + {Name: "yay", Version: "12.5.7-1"}, + {Name: "paru", Version: "2.0.4-1"}, + }, + Type: "multiinfo", + Version: 5, + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + var resp rpcResponse + httpResp, err := http.Get(srv.URL) + require.NoError(t, err) + defer httpResp.Body.Close() + require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp)) + + assert.Equal(t, 2, resp.ResultCount) + + // Simulate rpcInfoMulti result building + result := make(map[string]rpcResult, len(resp.Results)) + for _, r := range resp.Results { + result[r.Name] = r + } + assert.Equal(t, "12.5.7-1", result["yay"].Version) + assert.Equal(t, "2.0.4-1", result["paru"].Version) +} + +func TestToPackage(t *testing.T) { + r := rpcResult{ + Name: "paru", + Version: "2.0.4-1", + Description: "Feature packed AUR helper", + URL: "https://github.com/Morganamilo/paru", + } + pkg := r.toPackage() + assert.Equal(t, "paru", pkg.Name) + assert.Equal(t, "2.0.4-1", pkg.Version) + assert.Equal(t, "Feature packed AUR helper", pkg.Description) + assert.Equal(t, "aur", pkg.Repository) + assert.False(t, pkg.Installed) // AUR search results aren't installed +} + +func TestRPCSearchLive(t *testing.T) { + if testing.Short() { + t.Skip("skipping live AUR API test") + } + pkgs, err := rpcSearch(context.Background(), "yay") + require.NoError(t, err) + assert.NotEmpty(t, pkgs) + + found := false + for _, p := range pkgs { + if p.Name == "yay" { + found = true + assert.NotEmpty(t, p.Version) + assert.Equal(t, "aur", p.Repository) + break + } + } + assert.True(t, found, "expected to find 'yay' in AUR search results") +} + +func TestRPCInfoLive(t *testing.T) { + if testing.Short() { + t.Skip("skipping live AUR API test") + } + pkg, err := rpcInfo(context.Background(), "yay") + require.NoError(t, err) + assert.Equal(t, "yay", pkg.Name) + assert.NotEmpty(t, pkg.Version) +} + +func TestRPCInfoNotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping live AUR API test") + } + _, err := rpcInfo(context.Background(), "this-package-definitely-does-not-exist-12345") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/detect/detect_linux.go b/detect/detect_linux.go index 863d813..7b41027 100644 --- a/detect/detect_linux.go +++ b/detect/detect_linux.go @@ -6,6 +6,7 @@ import ( "github.com/gogrlx/snack" "github.com/gogrlx/snack/apk" "github.com/gogrlx/snack/apt" + "github.com/gogrlx/snack/aur" "github.com/gogrlx/snack/dnf" "github.com/gogrlx/snack/flatpak" "github.com/gogrlx/snack/pacman" @@ -26,6 +27,7 @@ func candidates() []managerFactory { } // allManagers returns all known manager factories (for ByName). +// Includes supplemental managers like AUR that aren't primary candidates. func allManagers() []managerFactory { - return candidates() + return append(candidates(), func() snack.Manager { return aur.New() }) }