10 Commits

Author SHA1 Message Date
4ea7c3f93b build(release): add Homebrew tap, install script, fix deprecations
- Replace deprecated brews with homebrew_casks
- Replace deprecated nfpms.builds with nfpms.ids
- Remove deprecated snapshot.name_template
- Create gogrlx/homebrew-tap repo for Homebrew distribution
- Add install.sh for curl-pipe-sh installation
- Users can now: brew install gogrlx/tap/snack
2026-03-25 19:08:06 +00:00
5863cea51e chore(deps): update all dependencies 2026-03-10 17:55:30 +00:00
a171459a66 fix(flatpak): remove duplicate stubs in flatpak_other.go
Functions latestVersion, listUpgrades, upgradeAvailable, versionCmp
were declared in both flatpak_other.go and capabilities_other.go,
causing build failures on non-Linux platforms.
2026-03-10 17:54:14 +00:00
84e4f8e2ff feat(aur): rewrite with go-git, RPC batch queries, functional options
- Replace shell git clone with go-git for cloning/pulling PKGBUILDs
- Add rpcInfoMulti for batch AUR queries (single HTTP request)
- Add functional options: WithBuildDir, WithMakepkgFlags
- Implement proper remove/purge via pacman -R/-Rns
- Fix temp directory leak: buildPackage returns cleanup path
- Remove NameNormalizer (AUR names are plain identifiers)
- Update README capability matrix
- Remove duplicate platform stubs (flatpak, ports)
2026-03-10 17:41:03 +00:00
1a51a40e4e Merge branch 'cd/aur-implementation': AUR rewrite with go-git, RPC batch queries, functional options
# Conflicts:
#	flatpak/capabilities.go
#	ports/capabilities.go
#	ports/capabilities_openbsd.go
#	ports/ports_test.go
#	snap/capabilities.go
#	snap/snap_linux.go
#	snap/snap_other.go
2026-03-10 17:35:15 +00:00
6db6e993f0 Merge pull request #43 from gogrlx/cd/update-deps-go1.26.1
chore: update Go to 1.26.1, fix formatting, add parse tests
2026-03-08 14:28:48 -04:00
1410e4888c chore: update Go to 1.26.1, fix goimports formatting, add tests
- Update go.mod from Go 1.26.0 to 1.26.1
- Update dependencies: golang.org/x/sync, golang.org/x/sys,
  charmbracelet/x/exp/charmtone, mattn/go-runewidth
- Fix goimports formatting in 10 files
- Add apk/normalize_test.go: tests for normalizeName and
  parseArchNormalize with all known arch suffixes
- Add rpm/parse_test.go: tests for parseList, parseInfo,
  parseArchSuffix, and normalizeName (all at 100% coverage)
- All tests pass with -race, staticcheck and go vet clean
2026-03-08 12:47:30 +00:00
b6b50491e2 feat(ports): add VersionQuerier, Cleaner, FileOwner, PackageUpgrader 2026-03-05 23:19:45 +00:00
a1d13e8a7d feat(snap): add Cleaner 2026-03-05 23:19:11 +00:00
c00133718e feat(flatpak): add VersionQuerier 2026-03-05 23:18:40 +00:00
28 changed files with 1083 additions and 889 deletions

View File

@@ -7,9 +7,6 @@ before:
hooks:
- go mod tidy
snapshot:
name_template: "{{ incpatch .Version }}-next"
builds:
- main: ./cmd/snack/
id: snack
@@ -44,7 +41,7 @@ archives:
nfpms:
- id: snack
package_name: snack
builds: [snack]
ids: [snack]
formats: [apk, deb, rpm]
bindir: /usr/bin
description: "A unified CLI for system package managers"
@@ -53,6 +50,19 @@ nfpms:
homepage: https://github.com/gogrlx/snack
vendor: Adatomic, Inc.
homebrew_casks:
- ids: [snack, snack-universal]
name: snack
binaries:
- snack
repository:
owner: gogrlx
name: homebrew-tap
directory: Casks
homepage: https://github.com/gogrlx/snack
description: "A unified CLI for system package managers"
license: 0BSD
release:
github:
owner: gogrlx

View File

@@ -14,7 +14,7 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
| Package | Manager | Platform | Status |
|---------|---------|----------|--------|
| `pacman` | pacman | Arch Linux | âś… |
| `aur` | AUR (makepkg) | Arch Linux | âś… |
| `aur` | AUR (RPC + makepkg) | Arch Linux | âś… |
| `apk` | apk-tools | Alpine Linux | âś… |
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | âś… |
| `dpkg` | dpkg | Debian/Ubuntu | âś… |
@@ -34,7 +34,7 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
|----------|:------------:|:----:|:-----:|:---------:|:--------:|:-------:|:------:|:--------:|:------:|:----------:|
| apt | âś… | âś… | âś… | âś… | âś… | âś… | - | âś… | âś… | âś… |
| pacman | âś… | - | âś… | âś… | - | - | âś… | âś… | âś… | âś… |
| aur | âś… | - | âś… | - | - | - | - | âś… | - | âś… |
| aur | âś… | - | âś… | - | - | - | - | - | - | âś… |
| apk | âś… | - | âś… | âś… | - | - | - | âś… | âś… | âś… |
| dnf | âś… | âś… | âś… | âś… | âś… | âś… | âś… | âś… | âś… | âś… |
| flatpak | âś… | - | âś… | - | âś… | - | - | âś… | - | âś… |

View File

@@ -23,7 +23,7 @@ func TestSplitNameVersion(t *testing.T) {
{"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"},
{"a-b-c-3.0", "a-b-c", "3.0"},
{"single", "single", ""},
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
{"pkg-0", "pkg", "0"},
}
for _, tt := range tests {
@@ -289,10 +289,10 @@ func TestParseInfoEdgeCases(t *testing.T) {
func TestParseInfoNameVersion(t *testing.T) {
tests := []struct {
name string
input string
wantN string
wantV string
name string
input string
wantN string
wantV string
}{
{
name: "standard",
@@ -435,8 +435,8 @@ func TestParseUpgradeSimulation(t *testing.T) {
wantLen: 0,
},
{
name: "single upgrade",
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
name: "single upgrade",
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "curl", Version: "8.6.0-r0", Installed: true},
@@ -462,8 +462,8 @@ OK: 123 MiB in 45 packages
wantLen: 0,
},
{
name: "upgrade without version parens",
input: "(1/1) Upgrading busybox\n",
name: "upgrade without version parens",
input: "(1/1) Upgrading busybox\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "busybox", Version: "", Installed: true},

View File

@@ -17,14 +17,14 @@ func normalizeName(name string) string {
// - package-aarch64
func parseArchNormalize(name string) (string, string) {
knownArchs := map[string]bool{
"x86_64": true,
"x86": true,
"aarch64": true,
"armhf": true,
"armv7": true,
"ppc64le": true,
"s390x": true,
"riscv64": true,
"x86_64": true,
"x86": true,
"aarch64": true,
"armhf": true,
"armv7": true,
"ppc64le": true,
"s390x": true,
"riscv64": true,
"loongarch64": true,
}

71
apk/normalize_test.go Normal file
View File

@@ -0,0 +1,71 @@
package apk
import (
"testing"
)
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"curl", "curl"},
{"curl-x86_64", "curl"},
{"openssl-aarch64", "openssl"},
{"musl-armhf", "musl"},
{"busybox-armv7", "busybox"},
{"lib-ssl-dev-x86", "lib-ssl-dev"},
{"zlib-ppc64le", "zlib"},
{"kernel-s390x", "kernel"},
{"toolchain-riscv64", "toolchain"},
{"app-loongarch64", "app"},
// No arch suffix — unchanged
{"python", "python"},
{"go", "go"},
{"", ""},
// Suffix that isn't an arch — unchanged
{"my-pkg-foo", "my-pkg-foo"},
{"libfoo-1.0", "libfoo-1.0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArchNormalize(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantArch string
}{
{"x86_64", "curl-x86_64", "curl", "x86_64"},
{"x86", "musl-x86", "musl", "x86"},
{"aarch64", "openssl-aarch64", "openssl", "aarch64"},
{"armhf", "busybox-armhf", "busybox", "armhf"},
{"armv7", "lib-armv7", "lib", "armv7"},
{"ppc64le", "app-ppc64le", "app", "ppc64le"},
{"s390x", "pkg-s390x", "pkg", "s390x"},
{"riscv64", "tool-riscv64", "tool", "riscv64"},
{"loongarch64", "gcc-loongarch64", "gcc", "loongarch64"},
{"no arch", "curl", "curl", ""},
{"unknown suffix", "pkg-foobar", "pkg-foobar", ""},
{"empty", "", "", ""},
{"hyphen but not arch", "lib-ssl-dev", "lib-ssl-dev", ""},
{"multi hyphen with arch", "lib-ssl-dev-x86_64", "lib-ssl-dev", "x86_64"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotArch := parseArchNormalize(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("parseArchNormalize(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}

View File

@@ -191,7 +191,6 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
return repos, nil
}
func addRepo(ctx context.Context, repo snack.Repository) error {
repoLine := repo.URL
if repo.Type != "" {

View File

@@ -14,8 +14,8 @@ func TestParseList_EdgeCases(t *testing.T) {
}{
{"empty", "", 0},
{"whitespace_only", " \n \n ", 0},
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
{"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1},
{"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2},
{"trailing_newline", "bash\t5.2-1\n", 1},

View File

@@ -1,5 +1,10 @@
// Package aur provides Go bindings for AUR (Arch User Repository) package building.
// AUR packages are built from source using makepkg.
// 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 (
@@ -8,91 +13,105 @@ import (
"github.com/gogrlx/snack"
)
// AUR wraps makepkg and AUR helper tools for building packages from the AUR.
// 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.
// New returns a new AUR manager with default settings.
func New() *AUR {
return &AUR{}
}
// Compile-time interface checks.
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
_ snack.NameNormalizer = (*AUR)(nil)
)
// 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 makepkg is present on the system.
// Available reports whether the AUR toolchain (git, makepkg, pacman) is present.
func (a *AUR) Available() bool { return available() }
// Install one or more packages from the AUR.
// 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 install(ctx, pkgs, opts...)
return a.install(ctx, pkgs, opts...)
}
// Remove is not directly supported by AUR (use pacman).
// 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 is not directly supported by AUR (use pacman).
// 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 all AUR packages (requires re-building from source).
// 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 upgrade(ctx, opts...)
return a.upgradeAll(ctx, opts...)
}
// Update is a no-op for AUR (packages are fetched on demand).
func (a *AUR) Update(ctx context.Context) error {
return update(ctx)
// 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 installed packages that came from the AUR.
// 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 for packages matching the query.
// Search queries the AUR RPC API for packages matching the query.
func (a *AUR) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
return rpcSearch(ctx, query)
}
// Info returns details about a specific AUR package.
// 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 info(ctx, pkg)
return rpcInfo(ctx, pkg)
}
// IsInstalled reports whether a package from the AUR is currently installed.
// 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 an AUR package.
// Version returns the installed version of a package.
func (a *AUR) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of an AUR package name.
func (a *AUR) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (a *AUR) ParseArch(name string) (string, string) {
return parseArch(name)
}

View File

@@ -5,67 +5,59 @@ 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 aurRPC = "https://aur.archlinux.org/rpc/v5"
const aurGitBase = "https://aur.archlinux.org"
func available() bool {
_, err := exec.LookPath("makepkg")
return err == nil
for _, tool := range []string{"makepkg", "pacman"} {
if _, err := exec.LookPath(tool); err != nil {
return false
}
}
return true
}
// 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"`
// 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
}
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 {
return nil, err
}
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
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
// 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 {
// Check if already installed
if !o.Reinstall && !o.DryRun {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
@@ -77,8 +69,29 @@ func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (sn
}
}
if err := installPkg(ctx, t.Name, o); err != nil {
return snack.InstallResult{}, err
pkgFile, cleanupDir, err := a.buildPackage(ctx, t)
if err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
}
if o.DryRun {
if cleanupDir != "" {
os.RemoveAll(cleanupDir)
}
installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"})
continue
}
args := []string{"-U", "--noconfirm", pkgFile}
installErr := func() error {
if cleanupDir != "" {
defer os.RemoveAll(cleanupDir)
}
_, err := runPacman(ctx, args, o.Sudo)
return err
}()
if installErr != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, installErr)
}
v, _ := version(ctx, t.Name)
@@ -89,92 +102,317 @@ func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (sn
Installed: true,
})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
func installPkg(ctx context.Context, pkg string, opts snack.Options) error {
tmpDir, err := os.MkdirTemp("", "aur-"+pkg)
if err != nil {
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)
// buildPackage clones the AUR git repo for a package and runs makepkg.
// Returns the path to the built .pkg.tar.zst file and an optional cleanup
// directory (non-empty only when a temp dir was created; caller must remove it).
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (pkgPath string, cleanupDir string, err 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)
}
return fmt.Errorf("aur: makepkg %s: %s: %w", pkg, se, err)
buildDir = tmp
cleanupDir = tmp
}
pkgDir := filepath.Join(buildDir, t.Name)
// Clone or update the PKGBUILD repo
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
return "", cleanupDir, 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 "", cleanupDir, 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 "", cleanupDir, fmt.Errorf("makepkg %s: no package file produced", t.Name)
}
return matches[len(matches)-1], cleanupDir, 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(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
return snack.RemoveResult{}, fmt.Errorf("aur: remove not supported, use pacman instead")
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(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return fmt.Errorf("aur: purge 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 upgrade(ctx context.Context, opts ...snack.Option) error {
aurPkgs, err := list(ctx)
// 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
}
for _, p := range aurPkgs {
result, err := aurQuery(ctx, "info", p.Name)
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
continue // skip packages where vercmp fails
}
if result.ResultCount == 0 {
continue
if cmp < 0 {
upgrades = append(upgrades, snack.Package{
Name: inst.Name,
Version: aurPkg.Version,
Repository: "aur",
Installed: true,
})
}
if result.Results[0].Version != p.Version {
if _, err := install(ctx, []snack.Target{{Name: p.Name}}, opts...); err != nil {
return err
}
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
}
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
}
// 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") {
@@ -195,188 +433,3 @@ 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...)
}

View File

@@ -10,7 +10,7 @@ import (
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
func (a *AUR) install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}
@@ -22,11 +22,7 @@ func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
func (a *AUR) upgradeAll(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
@@ -34,14 +30,6 @@ func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
@@ -70,10 +58,6 @@ func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
func (a *AUR) cleanBuildDir() error {
return snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -3,123 +3,63 @@ package aur
import (
"testing"
"github.com/gogrlx/snack"
"github.com/stretchr/testify/assert"
)
// Compile-time interface checks
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
_ snack.NameNormalizer = (*AUR)(nil)
)
func TestNew(t *testing.T) {
a := New()
if a == nil {
t.Fatal("New() returned nil")
}
}
func TestName(t *testing.T) {
a := New()
if a.Name() != "aur" {
t.Errorf("Name() = %q, want %q", a.Name(), "aur")
}
}
func TestNormalizeName(t *testing.T) {
func TestParsePackageList(t *testing.T) {
tests := []struct {
input string
want string
name string
input string
expect int
}{
{"yay", "yay"},
{"paru", "paru"},
{"google-chrome", "google-chrome"},
{"visual-studio-code-bin", "visual-studio-code-bin"},
{"", ""},
}
a := New()
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := a.NormalizeName(tt.input)
if got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArch(t *testing.T) {
tests := []struct {
input string
wantName string
wantArch string
}{
{"yay", "yay", ""},
{"paru", "paru", ""},
{"google-chrome", "google-chrome", ""},
}
a := New()
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
gotName, gotArch := a.ParseArch(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("ParseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"PackageUpgrade", caps.PackageUpgrade, true},
{"NameNormalize", caps.NameNormalize, true},
// AUR does not support these
{"Hold", caps.Hold, false},
{"FileOwnership", caps.FileOwnership, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"DryRun", caps.DryRun, false},
{
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) {
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
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 TestInterfaceNonCompliance(t *testing.T) {
var m snack.Manager = New()
if _, ok := m.(snack.Holder); ok {
t.Error("AUR should not implement Holder")
}
if _, ok := m.(snack.FileOwner); ok {
t.Error("AUR should not implement FileOwner")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("AUR should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("AUR should not implement KeyManager")
}
if _, ok := m.(snack.Grouper); ok {
t.Error("AUR should not implement Grouper")
}
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)
}

View File

@@ -6,22 +6,30 @@ import (
"github.com/gogrlx/snack"
)
// LatestVersion returns the latest version of an AUR package.
// 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 AUR packages that have newer versions available.
// 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.
// 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 vercmp.
// 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)
}
@@ -33,14 +41,14 @@ func (a *AUR) Autoremove(ctx context.Context, opts ...snack.Option) error {
return autoremove(ctx, opts...)
}
// Clean is a no-op for AUR (builds use temp directories).
func (a *AUR) Clean(ctx context.Context) error {
return clean(ctx)
// 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 upgradePackages(ctx, pkgs, opts...)
return a.install(ctx, pkgs, opts...)
}

View File

@@ -1,15 +0,0 @@
package aur
// normalizeName returns the canonical form of an AUR package name.
// AUR package names are simple identifiers without architecture or version
// suffixes, so this is essentially a pass-through.
func normalizeName(name string) string {
return name
}
// parseArch extracts the architecture from a package name if present.
// AUR package names do not include architecture suffixes,
// so this returns the name unchanged with an empty string.
func parseArch(name string) (string, string) {
return name, ""
}

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/gogrlx/snack"
)
@@ -116,3 +117,23 @@ func rpcInfo(ctx context.Context, pkg string) (*snack.Package, error) {
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
}

View File

@@ -235,10 +235,10 @@ func clean(ctx context.Context) error {
// brewInfoJSON represents the JSON output from `brew info --json=v2`.
type brewInfoJSON struct {
Formulae []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Desc string `json:"desc"`
Versions struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Desc string `json:"desc"`
Versions struct {
Stable string `json:"stable"`
} `json:"versions"`
Installed []struct {
@@ -246,24 +246,24 @@ type brewInfoJSON struct {
} `json:"installed"`
} `json:"formulae"`
Casks []struct {
Token string `json:"token"`
Token string `json:"token"`
Name []string `json:"name"`
Desc string `json:"desc"`
Version string `json:"version"`
Desc string `json:"desc"`
Version string `json:"version"`
} `json:"casks"`
}
// brewOutdatedJSON represents the JSON output from `brew outdated --json=v2`.
type brewOutdatedJSON struct {
Formulae []struct {
Name string `json:"name"`
Name string `json:"name"`
InstalledVersions []string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
CurrentVersion string `json:"current_version"`
} `json:"formulae"`
Casks []struct {
Name string `json:"name"`
Name string `json:"name"`
InstalledVersions string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
CurrentVersion string `json:"current_version"`
} `json:"casks"`
}

View File

@@ -4,16 +4,16 @@ package snack
// Useful for grlx to determine what operations are available before
// attempting them.
type Capabilities struct {
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
PackageUpgrade bool
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
PackageUpgrade bool
}
// GetCapabilities probes a Manager for all optional interface support.
@@ -29,15 +29,15 @@ func GetCapabilities(m Manager) Capabilities {
_, dr := m.(DryRunner)
_, pu := m.(PackageUpgrader)
return Capabilities{
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
PackageUpgrade: pu,
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
PackageUpgrade: pu,
}
}

View File

@@ -18,15 +18,15 @@ func (m *mockManager) Remove(context.Context, []snack.Target, ...snack.Option) (
return snack.RemoveResult{}, nil
}
func (m *mockManager) Purge(context.Context, []snack.Target, ...snack.Option) error { return nil }
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
func (m *mockManager) Update(context.Context) error { return nil }
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
func (m *mockManager) Available() bool { return true }
func (m *mockManager) Name() string { return "mock" }
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
func (m *mockManager) Update(context.Context) error { return nil }
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
func (m *mockManager) Available() bool { return true }
func (m *mockManager) Name() string { return "mock" }
// fullMockManager implements Manager plus all optional interfaces.
type fullMockManager struct {

View File

@@ -8,16 +8,16 @@ import (
// Compile-time interface assertions — DNF implements all optional interfaces.
var (
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
_ snack.DryRunner = (*DNF)(nil)
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
_ snack.DryRunner = (*DNF)(nil)
_ snack.PackageUpgrader = (*DNF)(nil)
)

View File

@@ -0,0 +1,56 @@
//go:build linux
package flatpak
import (
"context"
"fmt"
"strings"
"github.com/gogrlx/snack"
)
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"remote-info", "flathub", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("flatpak latestVersion: %w", err)
}
// remote-info output is key:value like `flatpak info`
p := parseInfo(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"remote-ls", "--updates", "--columns=name,application,version,origin"})
if err != nil {
// No updates available may produce an error on some versions
if strings.Contains(err.Error(), "No updates") {
return nil, nil
}
return nil, fmt.Errorf("flatpak listUpgrades: %w", err)
}
return parseList(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if u.Name == pkg || u.Description == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}

View File

@@ -0,0 +1,25 @@
//go:build !linux
package flatpak
import (
"context"
"github.com/gogrlx/snack"
)
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
}

View File

@@ -198,49 +198,6 @@ func removeRepo(ctx context.Context, id string) error {
return err
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"remote-info", "flathub", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("flatpak latestVersion: %w", err)
}
p := parseInfo(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"remote-ls", "--updates", "--columns=name,application,version,origin"})
if err != nil {
if strings.Contains(err.Error(), "No updates") {
return nil, nil
}
return nil, fmt.Errorf("flatpak listUpgrades: %w", err)
}
return parseList(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if u.Name == pkg || u.Description == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target

View File

@@ -62,21 +62,7 @@ func removeRepo(_ context.Context, _ 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 upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform

35
go.mod
View File

@@ -1,9 +1,9 @@
module github.com/gogrlx/snack
go 1.26.0
go 1.26.1
require (
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/fang v1.0.0
github.com/go-git/go-git/v5 v5.17.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@@ -11,17 +11,17 @@ require (
)
require (
charm.land/lipgloss/v2 v2.0.0 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -33,7 +33,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
@@ -51,12 +51,13 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -72,15 +73,15 @@ require (
github.com/muesli/roff v0.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
@@ -95,10 +96,10 @@ require (
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect

74
go.sum
View File

@@ -1,5 +1,5 @@
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -9,28 +9,28 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg6
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 h1:wQs/I0JSEkcHzobvAgfzeJOKm9A8mkeDOkWQxAo0AZc=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -58,8 +58,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -109,10 +109,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -126,8 +128,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -162,8 +164,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -175,15 +177,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -229,15 +231,15 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -249,11 +251,11 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

53
install.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/sh
# Install snack - a unified CLI for system package managers
# Usage: curl -sSfL https://raw.githubusercontent.com/gogrlx/snack/main/install.sh | sh
set -e
REPO="gogrlx/snack"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
# Detect OS and arch
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
armv*) ARCH="arm" ;;
esac
# macOS universal binary
if [ "$OS" = "darwin" ]; then
ARCH="universal"
fi
echo "Detected: ${OS}/${ARCH}"
# Get latest release tag
TAG="$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
VERSION="${TAG#v}"
if [ -z "$VERSION" ]; then
echo "Error: could not determine latest version" >&2
exit 1
fi
echo "Installing snack ${VERSION}..."
TARBALL="snack-${VERSION}-${OS}-${ARCH}.tar.gz"
URL="https://github.com/${REPO}/releases/download/${TAG}/${TARBALL}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -sSfL "$URL" -o "${TMP}/${TARBALL}"
tar xzf "${TMP}/${TARBALL}" -C "$TMP"
if [ -w "$INSTALL_DIR" ]; then
mv "${TMP}/snack" "${INSTALL_DIR}/snack"
else
echo "Installing to ${INSTALL_DIR} (requires sudo)..."
sudo mv "${TMP}/snack" "${INSTALL_DIR}/snack"
fi
chmod +x "${INSTALL_DIR}/snack"
echo "snack ${VERSION} installed to ${INSTALL_DIR}/snack"

View File

@@ -0,0 +1,45 @@
//go:build !openbsd
package ports
import (
"context"
"github.com/gogrlx/snack"
)
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 clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -49,39 +49,3 @@ func isInstalled(_ context.Context, _ string) (bool, error) {
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ 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 upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -4,182 +4,25 @@ import (
"testing"
)
func TestParseList(t *testing.T) {
input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" {
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
}
if pkgs[0].Description != "The GNU Bourne Again shell" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
}
func TestParseInfo(t *testing.T) {
input := `Name : bash
Version : 5.1.8
Release : 6.el9
Architecture: x86_64
Install Date: Mon 01 Jan 2024 12:00:00 AM UTC
Group : System Environment/Shells
Size : 7896043
License : GPLv3+
Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123
Source RPM : bash-5.1.8-6.el9.src.rpm
Build Date : Mon 01 Jan 2024 12:00:00 AM UTC
Build Host : builder.example.com
Packager : CentOS Buildsys <bugs@centos.org>
Vendor : CentOS
URL : https://www.gnu.org/software/bash
Summary : The GNU Bourne Again shell
Description :
The GNU Bourne Again shell (Bash) is a shell or command language
interpreter that is compatible with the Bourne shell (sh).
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected package, got nil")
}
if p.Name != "bash" {
t.Errorf("Name = %q, want bash", p.Name)
}
if p.Version != "5.1.8-6.el9" {
t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version)
}
if p.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", p.Arch)
}
if p.Description != "The GNU Bourne Again shell" {
t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description)
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
input, want string
input string
want string
}{
{"nginx", "nginx"},
{"nginx.x86_64", "nginx"},
{"curl.aarch64", "curl"},
{"bash.noarch", "bash"},
{"python3", "python3"},
}
for _, tt := range tests {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArchSuffix(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"nginx.x86_64", "nginx", "x86_64"},
{"bash", "bash", ""},
{"glibc.i686", "glibc", "i686"},
}
for _, tt := range tests {
name, arch := parseArchSuffix(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
}
}
}
// --- Edge case tests ---
func TestParseListEmpty(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListSinglePackage(t *testing.T) {
input := "curl\t7.76.1-23.el9\tA utility\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseListNoDescription(t *testing.T) {
input := "bash\t5.1.8-6.el9\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "" {
t.Errorf("Description = %q, want empty", pkgs[0].Description)
}
}
func TestParseListMalformedLines(t *testing.T) {
input := "bash\t5.1.8-6.el9\tShell\nno-tab-here\ncurl\t7.76.1\tHTTP tool\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
}
}
func TestParseInfoEmpty(t *testing.T) {
p := parseInfo("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoNoName(t *testing.T) {
input := `Version : 1.0
Architecture: x86_64
`
p := parseInfo(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseInfoArchField(t *testing.T) {
// Test both "Architecture" and "Arch" key forms
input := `Name : test
Version : 1.0
Release : 1.el9
Arch : aarch64
Summary : Test package
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
if p.Arch != "aarch64" {
t.Errorf("Arch = %q, want aarch64", p.Arch)
}
}
func TestNormalizeNameEdgeCases(t *testing.T) {
tests := []struct {
input, want string
}{
{"curl.noarch", "curl"},
{"kernel.aarch64", "kernel"},
{"bash.i686", "bash"},
{"glibc.i386", "glibc"},
{"libfoo.armv7hl", "libfoo"},
{"module.ppc64le", "module"},
{"app.s390x", "app"},
{"source.src", "source"},
{"nodot", "nodot"},
{"", ""},
{"pkg.unknown.ext", "pkg.unknown.ext"},
{"name.with.dots.x86_64", "name.with.dots"},
{"python3.11", "python3.11"},
{"glibc.s390x", "glibc"},
{"kernel.src", "kernel"},
{".x86_64", ""},
{"pkg.ppc64le", "pkg"},
{"pkg.armv7hl", "pkg"},
{"pkg.i386", "pkg"},
{"pkg.unknown", "pkg.unknown"},
{"multi.dot.x86_64", "multi.dot"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
@@ -191,30 +34,202 @@ func TestNormalizeNameEdgeCases(t *testing.T) {
}
}
func TestParseArchSuffixEdgeCases(t *testing.T) {
func TestParseArchSuffix(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
name string
input string
wantName string
wantArch string
}{
{"", "", ""},
{"pkg.i386", "pkg", "i386"},
{"pkg.ppc64le", "pkg", "ppc64le"},
{"pkg.s390x", "pkg", "s390x"},
{"pkg.armv7hl", "pkg", "armv7hl"},
{"pkg.src", "pkg", "src"},
{"pkg.aarch64", "pkg", "aarch64"},
{"pkg.noarch", "pkg", "noarch"},
{"pkg.unknown", "pkg.unknown", ""},
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
{".noarch", "", "noarch"},
{"pkg.x86_64.extra", "pkg.x86_64.extra", ""},
{"x86_64", "nginx.x86_64", "nginx", "x86_64"},
{"noarch", "bash.noarch", "bash", "noarch"},
{"aarch64", "kernel.aarch64", "kernel", "aarch64"},
{"i686", "glibc.i686", "glibc", "i686"},
{"i386", "compat.i386", "compat", "i386"},
{"armv7hl", "lib.armv7hl", "lib", "armv7hl"},
{"ppc64le", "app.ppc64le", "app", "ppc64le"},
{"s390x", "z.s390x", "z", "s390x"},
{"src", "pkg.src", "pkg", "src"},
{"no dot", "curl", "curl", ""},
{"unknown arch", "pkg.foobar", "pkg.foobar", ""},
{"empty", "", "", ""},
{"multiple dots", "a.b.x86_64", "a.b", "x86_64"},
{"dot but not arch", "libfoo.so", "libfoo.so", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArchSuffix(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Run(tt.name, func(t *testing.T) {
gotName, gotArch := parseArchSuffix(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}
func TestParseList(t *testing.T) {
input := "bash\t5.2.15-3.fc38\tThe GNU Bourne Again shell\n" +
"curl\t8.0.1-1.fc38\tA utility for getting files from remote servers\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2.15-3.fc38" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[0].Description != "The GNU Bourne Again shell" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
}
func TestParseListEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("whitespace only", func(t *testing.T) {
pkgs := parseList(" \n\n \n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single entry no description", func(t *testing.T) {
pkgs := parseList("vim\t9.0.1\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.1" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("single field line skipped", func(t *testing.T) {
pkgs := parseList("justname\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=2 tab fields), got %d", len(pkgs))
}
})
t.Run("description with tabs", func(t *testing.T) {
pkgs := parseList("pkg\t1.0\tA description\twith tabs\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
// SplitN with 3 means the third part includes everything after the second tab
if pkgs[0].Description != "A description\twith tabs" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
}
func TestParseInfo(t *testing.T) {
input := `Name : curl
Version : 8.0.1
Release : 1.fc38
Architecture: x86_64
Summary : A utility for getting files from remote servers
`
pkg := parseInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "curl" {
t.Errorf("expected name 'curl', got %q", pkg.Name)
}
if pkg.Version != "8.0.1-1.fc38" {
t.Errorf("expected version '8.0.1-1.fc38', got %q", pkg.Version)
}
if pkg.Arch != "x86_64" {
t.Errorf("expected arch 'x86_64', got %q", pkg.Arch)
}
if pkg.Description != "A utility for getting files from remote servers" {
t.Errorf("unexpected description: %q", pkg.Description)
}
}
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkg := parseInfo("")
if pkg != nil {
t.Error("expected nil for empty input")
}
})
t.Run("name only", func(t *testing.T) {
pkg := parseInfo("Name : bash\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "bash" {
t.Errorf("expected bash, got %q", pkg.Name)
}
})
t.Run("no name returns nil", func(t *testing.T) {
pkg := parseInfo("Version : 1.0\nArch : x86_64\n")
if pkg != nil {
t.Error("expected nil when no Name field")
}
})
t.Run("version without release", func(t *testing.T) {
pkg := parseInfo("Name : test\nVersion : 2.5\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "2.5" {
t.Errorf("expected version '2.5', got %q", pkg.Version)
}
})
t.Run("release without version", func(t *testing.T) {
// Release only appends if version is non-empty
pkg := parseInfo("Name : test\nRelease : 3.el9\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "" {
t.Errorf("expected empty version (release alone shouldn't set it), got %q", pkg.Version)
}
})
t.Run("arch key variant", func(t *testing.T) {
pkg := parseInfo("Name : test\nArch : aarch64\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Arch != "aarch64" {
t.Errorf("expected aarch64, got %q", pkg.Arch)
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
pkg := parseInfo("Name : test\nrandom line\nSummary : A tool\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A tool" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
t.Run("value with colons", func(t *testing.T) {
pkg := parseInfo("Name : myapp\nSummary : A tool: does things: well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A tool: does things: well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}