mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Replace exec.Command git clone/pull with go-git's PlainCloneContext and Worktree.Pull. Removes git binary dependency — only makepkg and pacman are needed on the host now.
424 lines
11 KiB
Go
424 lines
11 KiB
Go
//go:build linux
|
|
|
|
package aur
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"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"
|
|
|
|
func available() bool {
|
|
for _, tool := range []string{"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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
if err != nil {
|
|
if err == transport.ErrRepositoryNotFound {
|
|
return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound)
|
|
}
|
|
return fmt.Errorf("aur clone %s: %w", pkg, 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
|
|
}
|