diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1f1d2e3 Binary files /dev/null and b/.DS_Store differ diff --git a/.crush/session b/.crush/session new file mode 100644 index 0000000..0595698 --- /dev/null +++ b/.crush/session @@ -0,0 +1,7 @@ +{ + "id": "641b604334578972", + "workspace_root": "/Users/tai/code/foss/snack", + "neovim_pid": 64655, + "created_at": "2026-03-05T20:14:55.362445-05:00", + "socket_path": "/var/folders/4r/k5jjbjzs2qv3mb4s008xs5500000gn/T/neocrush-501/641b604334578972.sock" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c719005 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,313 @@ +# AGENTS.md - snack + +Idiomatic Go wrappers for system package managers. Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem. + +## Quick Reference + +```bash +# Build +go build ./... + +# Unit tests (run anywhere) +go test -race ./... + +# Lint +go vet ./... + +# Integration tests (require real package manager) +go test -tags integration -v ./apt/ # On Debian/Ubuntu +go test -tags integration -v ./pacman/ # On Arch Linux +go test -tags integration -v ./apk/ # On Alpine +go test -tags integration -v ./dnf/ # On Fedora + +# Container-based integration tests (require Docker) +go test -tags containertest -v -timeout 15m . +``` + +## Project Structure + +``` +snack/ +├── snack.go # Core Manager interface + capability interfaces +├── types.go # Package, Options, Repository types + functional options +├── capabilities.go # GetCapabilities() runtime capability detection +├── errors.go # Sentinel errors (ErrNotFound, ErrPermissionDenied, etc.) +├── mutex.go # Locker type for per-provider mutex +├── target.go # Target helper functions +├── cmd/snack/ # CLI tool (cobra-based) +├── detect/ # Auto-detection of system package manager +│ ├── detect.go # Default(), All(), ByName() - shared logic +│ ├── detect_linux.go # Linux candidate ordering +│ ├── detect_freebsd.go # FreeBSD candidates +│ └── detect_openbsd.go # OpenBSD candidates +├── apt/ # Debian/Ubuntu (apt-get, apt-cache, dpkg-query) +├── apk/ # Alpine Linux +├── pacman/ # Arch Linux +├── dnf/ # Fedora/RHEL (supports dnf4 and dnf5) +├── rpm/ # RPM queries +├── dpkg/ # Low-level dpkg operations +├── flatpak/ # Flatpak +├── snap/ # Snapcraft +├── pkg/ # FreeBSD pkg(8) +├── ports/ # OpenBSD ports +└── aur/ # Arch User Repository (stub) +``` + +## Architecture Patterns + +### Interface Hierarchy + +All providers implement `snack.Manager` (the base interface). Extended capabilities are optional: + +```go +// Base - every provider +snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version + +// Optional - use type assertions +snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp +snack.Holder // Hold, Unhold, ListHeld (version pinning) +snack.Cleaner // Autoremove, Clean +snack.FileOwner // FileList, Owner +snack.RepoManager // ListRepos, AddRepo, RemoveRepo +snack.KeyManager // AddKey, RemoveKey, ListKeys +snack.Grouper // GroupList, GroupInfo, GroupInstall +snack.NameNormalizer // NormalizeName, ParseArch +``` + +Check capabilities at runtime: + +```go +caps := snack.GetCapabilities(mgr) +if caps.Hold { + mgr.(snack.Holder).Hold(ctx, []string{"nginx"}) +} +``` + +### Provider File Layout + +Each provider follows this pattern: + +``` +apt/ +├── apt.go # Public type + methods (delegates to platform-specific) +├── apt_linux.go # Linux implementation (actual CLI calls) +├── apt_other.go # Stub for non-Linux (returns ErrUnsupportedPlatform) +├── capabilities_linux.go # Extended capability implementations +├── capabilities_other.go # Capability stubs +├── normalize.go # Name normalization helpers +├── parse.go # Output parsing functions +├── apt_test.go # Unit tests (parsing, no system calls) +└── apt_integration_test.go # Integration tests (//go:build integration) +``` + +### Build Tags + +- **No tag**: Unit tests, run anywhere +- `integration`: Tests that require the actual package manager to be installed +- `containertest`: Tests using testcontainers (require Docker) + +### Per-Provider Mutex + +Each provider embeds `snack.Locker` and calls `Lock()`/`Unlock()` around mutating operations: + +```go +type Apt struct { + snack.Locker +} + +func (a *Apt) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() + return install(ctx, pkgs, opts...) +} +``` + +Different providers can run concurrently (apt + snap), but operations on the same provider serialize. + +### Functional Options + +Use functional options for operation configuration: + +```go +mgr.Install(ctx, snack.Targets("nginx"), + snack.WithSudo(), + snack.WithAssumeYes(), + snack.WithDryRun(), +) +``` + +Available options: `WithSudo()`, `WithAssumeYes()`, `WithDryRun()`, `WithVerbose()`, `WithRefresh()`, `WithReinstall()`, `WithRoot(path)`, `WithFromRepo(repo)`. + +### Target Type + +Packages are specified using `snack.Target`: + +```go +type Target struct { + Name string // Required + Version string // Optional version constraint + FromRepo string // Optional repository constraint + Source string // Local file path or URL +} + +// Convenience constructor for name-only targets +targets := snack.Targets("nginx", "redis", "curl") +``` + +## Code Conventions + +### Interface Compliance + +Verify interface compliance at compile time: + +```go +var ( + _ snack.Manager = (*Apt)(nil) + _ snack.VersionQuerier = (*Apt)(nil) + _ snack.Holder = (*Apt)(nil) +) +``` + +### Error Handling + +Use sentinel errors from `errors.go`: + +```go +snack.ErrNotInstalled // Package is not installed +snack.ErrNotFound // Package not found in repos +snack.ErrUnsupportedPlatform // Package manager unavailable +snack.ErrPermissionDenied // Need sudo +snack.ErrManagerNotFound // detect couldn't find a manager +snack.ErrDaemonNotRunning // snapd not running, etc. +``` + +Wrap with context: + +```go +return fmt.Errorf("apt-get install: %w", snack.ErrPermissionDenied) +``` + +### CLI Output Parsing + +Parse functions live in `parse.go` and are thoroughly unit-tested: + +```go +// apt/parse.go +func parseList(output string) []snack.Package { ... } +func parseSearch(output string) []snack.Package { ... } +func parseInfo(output string) (*snack.Package, error) { ... } + +// apt/apt_test.go +func TestParseList(t *testing.T) { + input := "bash\t5.2-1\tGNU Bourne Again SHell\n..." + pkgs := parseList(input) + // assertions +} +``` + +### Platform-Specific Stubs + +Non-supported platforms return `ErrUnsupportedPlatform`: + +```go +//go:build !linux + +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} +``` + +## Testing + +### Unit Tests + +Test parsing logic without system calls: + +```go +func TestParseList(t *testing.T) { + input := "bash\t5.2-1\tGNU Bourne Again SHell\ncoreutils\t9.1-1\tGNU core utilities\n" + pkgs := parseList(input) + assert.Len(t, pkgs, 2) + assert.Equal(t, "bash", pkgs[0].Name) +} +``` + +### Integration Tests + +Use `//go:build integration` tag. Test against the real system: + +```go +//go:build integration + +func TestIntegration_Apt(t *testing.T) { + mgr := apt.New() + if !mgr.Available() { + t.Skip("apt not available") + } + // Install, remove, query packages... +} +``` + +### Test Assertions + +Use testify: + +```go +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +require.NoError(t, err) // Fatal on error +assert.True(t, installed) // Continue on failure +assert.Equal(t, "apt", mgr.Name()) +``` + +## CI/CD + +GitHub Actions workflow at `.github/workflows/integration.yml`: + +- **Unit tests**: Run on ubuntu-latest +- **Integration tests**: Run in native containers (debian, alpine, archlinux, fedora) +- **Coverage**: Uploaded to Codecov + +Jobs test against: +- Debian (apt) +- Ubuntu (apt + snap) +- Fedora 39 (dnf4) +- Fedora latest (dnf5) +- Alpine (apk) +- Arch Linux (pacman) +- Ubuntu + Flatpak + +## Adding a New Provider + +1. Create directory: `newpkg/` +2. Create files following the pattern: + - `newpkg.go` - public interface + - `newpkg_linux.go` - Linux implementation + - `newpkg_other.go` - stubs + - `parse.go` - output parsing + - `newpkg_test.go` - parsing unit tests + - `newpkg_integration_test.go` - integration tests +3. Add compile-time interface checks +4. Register in `detect/detect_linux.go` (or appropriate platform file) +5. Add CI job in `.github/workflows/integration.yml` + +## Common Gotchas + +1. **Build tags**: Integration tests won't run without `-tags integration` +2. **Sudo**: Most operations need `snack.WithSudo()` when running as non-root +3. **Platform stubs**: Every function in `*_linux.go` needs a stub in `*_other.go` +4. **Mutex**: Always lock around mutating operations +5. **dnf4 vs dnf5**: The dnf package handles both versions with different parsing logic +6. **Architecture suffixes**: apt uses `pkg:amd64` format, use `NameNormalizer` to strip + +## Dependencies + +Key dependencies (from go.mod): + +- `github.com/spf13/cobra` - CLI framework +- `github.com/charmbracelet/fang` - Cobra context wrapper +- `github.com/stretchr/testify` - Test assertions +- `github.com/testcontainers/testcontainers-go` - Container-based integration tests diff --git a/README.md b/README.md index 3ecfef4..c729650 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,36 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem. ## Supported Package Managers -| Package | Manager | Platform | Extras | +| Package | Manager | Platform | Status | |---------|---------|----------|--------| -| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | DryRun, FileOwner, Holder, KeyManager, NameNormalizer, RepoManager | -| `dpkg` | dpkg | Debian/Ubuntu | DryRun, FileOwner, NameNormalizer | -| `dnf` | DNF 4/5 | Fedora/RHEL | DryRun, FileOwner, Grouper, Holder, KeyManager, NameNormalizer, RepoManager | -| `rpm` | RPM | Fedora/RHEL | FileOwner, NameNormalizer | -| `pacman` | pacman | Arch Linux | DryRun, FileOwner, Grouper | -| `aur` | AUR (makepkg) | Arch Linux | — | -| `apk` | apk-tools | Alpine Linux | DryRun, FileOwner | -| `flatpak` | Flatpak | Cross-distro | RepoManager | -| `snap` | snapd | Cross-distro | — | -| `pkg` | pkg(8) | FreeBSD | FileOwner | -| `ports` | ports/packages | OpenBSD | FileOwner | -| `detect` | Auto-detection | All | — | +| `pacman` | pacman | Arch Linux | ✅ | +| `aur` | AUR (makepkg) | Arch Linux | ✅ | +| `apk` | apk-tools | Alpine Linux | ✅ | +| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | ✅ | +| `dpkg` | dpkg | Debian/Ubuntu | ✅ | +| `dnf` | DNF | Fedora/RHEL | ✅ | +| `rpm` | RPM | Fedora/RHEL | ✅ | +| `flatpak` | Flatpak | Linux | ✅ | +| `snap` | snapd | Linux | ✅ | +| `brew` | Homebrew | macOS/Linux | ✅ | +| `pkg` | pkg(8) | FreeBSD | ✅ | +| `ports` | ports/packages | OpenBSD | ✅ | +| `detect` | Auto-detection | All | ✅ | -All providers implement `Manager`, `VersionQuerier`, `Cleaner`, and `PackageUpgrader`. The **Extras** column lists additional capabilities beyond that baseline. +### Capability Matrix + +| Provider | VersionQuery | Hold | Clean | FileOwner | RepoMgmt | KeyMgmt | Groups | NameNorm | DryRun | PkgUpgrade | +|----------|:------------:|:----:|:-----:|:---------:|:--------:|:-------:|:------:|:--------:|:------:|:----------:| +| apt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ | +| pacman | ✅ | - | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | ✅ | +| aur | ✅ | - | ✅ | - | - | - | - | ✅ | - | ✅ | +| apk | ✅ | - | ✅ | ✅ | - | - | - | ✅ | ✅ | ✅ | +| dnf | ✅ | - | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ | ✅ | +| flatpak | ✅ | - | ✅ | - | ✅ | - | - | ✅ | ✅ | ✅ | +| snap | ✅ | - | - | - | - | - | - | ✅ | - | ✅ | +| brew | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ | +| pkg | ✅ | ✅ | ✅ | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | +| ports | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ | ## Install @@ -52,12 +66,11 @@ func main() { ctx := context.Background() mgr := apt.New() - // Install packages - result, err := mgr.Install(ctx, snack.Targets("nginx", "curl"), snack.WithSudo(), snack.WithAssumeYes()) + // Install a package + _, err := mgr.Install(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes()) if err != nil { log.Fatal(err) } - fmt.Printf("Installed: %d, Unchanged: %d\n", len(result.Installed), len(result.Unchanged)) // Check if installed installed, err := mgr.IsInstalled(ctx, "nginx") @@ -65,14 +78,6 @@ func main() { log.Fatal(err) } fmt.Println("nginx installed:", installed) - - // Upgrade specific packages - if up, ok := mgr.(snack.PackageUpgrader); ok { - _, err := up.UpgradePackages(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes()) - if err != nil { - log.Fatal(err) - } - } } ``` @@ -86,11 +91,6 @@ if err != nil { log.Fatal(err) } fmt.Println("Detected:", mgr.Name()) - -// All available managers -for _, m := range detect.All() { - fmt.Println(m.Name()) -} ``` ## Interfaces @@ -99,21 +99,17 @@ snack uses a layered interface design. Every provider implements `Manager` (the ```go // Base — every provider -snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version +snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version -// Core optional — implemented by all providers -snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp -snack.Cleaner // Autoremove, Clean (orphan/cache cleanup) -snack.PackageUpgrader // UpgradePackages (upgrade specific packages) - -// Provider-specific — type-assert to check -snack.Holder // Hold, Unhold, ListHeld, IsHeld (version pinning) -snack.FileOwner // FileList, Owner (file-to-package queries) -snack.RepoManager // ListRepos, AddRepo, RemoveRepo -snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys) -snack.Grouper // GroupList, GroupInfo, GroupInstall, GroupIsInstalled -snack.NameNormalizer // NormalizeName, ParseArch -snack.DryRunner // SupportsDryRun (honors WithDryRun option) +// Optional capabilities — type-assert to check +snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp +snack.Holder // Hold, Unhold, ListHeld (version pinning) +snack.Cleaner // Autoremove, Clean (orphan/cache cleanup) +snack.FileOwner // FileList, Owner (file-to-package queries) +snack.RepoManager // ListRepos, AddRepo, RemoveRepo +snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys) +snack.Grouper // GroupList, GroupInfo, GroupInstall +snack.NameNormalizer // NormalizeName, ParseArch ``` Check capabilities at runtime: @@ -123,49 +119,8 @@ caps := snack.GetCapabilities(mgr) if caps.Hold { mgr.(snack.Holder).Hold(ctx, []string{"nginx"}) } -if caps.FileOwnership { - owner, _ := mgr.(snack.FileOwner).Owner(ctx, "/usr/bin/curl") - fmt.Println("Owned by:", owner) -} ``` -## Options - -All mutating operations accept functional options: - -```go -snack.WithSudo() // prepend sudo -snack.WithAssumeYes() // auto-confirm prompts -snack.WithDryRun() // simulate (if DryRunner) -snack.WithVerbose() // verbose output -snack.WithRefresh() // refresh index before operation -snack.WithReinstall() // reinstall even if current -snack.WithRoot("/mnt") // alternate root filesystem -snack.WithFromRepo("sid") // install from specific repository -``` - -## CLI - -A companion CLI is included at `cmd/snack`: - -```bash -snack install nginx curl # install packages -snack remove nginx # remove packages -snack upgrade # upgrade all packages -snack update # refresh package index -snack search redis # search for packages -snack info nginx # show package details -snack list # list installed packages -snack which /usr/bin/curl # find owning package -snack hold nginx # pin package version -snack unhold nginx # unpin package version -snack clean # autoremove + clean cache -snack detect # show detected managers + capabilities -snack version # show version -``` - -Global flags: `--manager `, `--sudo`, `--yes`, `--dry-run` - ## Design - **Thin CLI wrappers** — each sub-package wraps a package manager's CLI tools. No FFI, no library bindings. @@ -176,6 +131,27 @@ Global flags: `--manager `, `--sudo`, `--yes`, `--dry-run` - **Platform-safe** — build tags ensure packages compile everywhere but only run where appropriate. - **No root assumption** — use `snack.WithSudo()` when elevated privileges are needed. +## Implementation Priority + +1. pacman + AUR (Arch Linux) +2. apk (Alpine Linux) +3. apt + dpkg (Debian/Ubuntu) +4. dnf + rpm (Fedora/RHEL) +5. flatpak + snap (cross-distro) +6. pkg + ports (BSD) + +## CLI + +A companion CLI tool is planned for direct terminal usage: + +```bash +snack install nginx +snack remove nginx +snack search redis +snack list +snack upgrade +``` + ## License 0BSD — see [LICENSE](LICENSE). diff --git a/apk/apk.go b/apk/apk.go index ddfa2a4..5c0c94a 100644 --- a/apk/apk.go +++ b/apk/apk.go @@ -17,9 +17,12 @@ func New() *Apk { return &Apk{} } -// compile-time check -var _ snack.Manager = (*Apk)(nil) -var _ snack.PackageUpgrader = (*Apk)(nil) +// Compile-time interface checks. +var ( + _ snack.Manager = (*Apk)(nil) + _ snack.PackageUpgrader = (*Apk)(nil) + _ snack.NameNormalizer = (*Apk)(nil) +) // Name returns "apk". func (a *Apk) Name() string { return "apk" } @@ -93,3 +96,13 @@ func (a *Apk) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ... defer a.Unlock() return upgradePackages(ctx, pkgs, opts...) } + +// NormalizeName returns the canonical form of a package name. +func (a *Apk) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (a *Apk) ParseArch(name string) (string, string) { + return parseArchNormalize(name) +} diff --git a/apk/apk_test.go b/apk/apk_test.go index 577fd30..2808116 100644 --- a/apk/apk_test.go +++ b/apk/apk_test.go @@ -402,8 +402,8 @@ func TestCapabilities(t *testing.T) { if caps.Groups { t.Error("expected Groups=false") } - if caps.NameNormalize { - t.Error("expected NameNormalize=false") + if !caps.NameNormalize { + t.Error("expected NameNormalize=true") } if !caps.PackageUpgrade { t.Error("expected PackageUpgrade=true") diff --git a/apk/capabilities.go b/apk/capabilities.go index 803f7de..625e497 100644 --- a/apk/capabilities.go +++ b/apk/capabilities.go @@ -11,6 +11,7 @@ var ( _ snack.VersionQuerier = (*Apk)(nil) _ snack.Cleaner = (*Apk)(nil) _ snack.FileOwner = (*Apk)(nil) + _ snack.NameNormalizer = (*Apk)(nil) _ snack.DryRunner = (*Apk)(nil) ) diff --git a/apk/capabilities_linux.go b/apk/capabilities_linux.go index 2dbdc2e..7ebe3d0 100644 --- a/apk/capabilities_linux.go +++ b/apk/capabilities_linux.go @@ -44,45 +44,6 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) { return parseUpgradeSimulation(string(out)), nil } -// parseUpgradeSimulation parses `apk upgrade --simulate` output. -// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)" -func parseUpgradeSimulation(output string) []snack.Package { - var pkgs []snack.Package - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if !strings.Contains(line, "Upgrading") { - continue - } - // "(1/3) Upgrading pkg (oldver -> newver)" - idx := strings.Index(line, "Upgrading ") - if idx < 0 { - continue - } - rest := line[idx+len("Upgrading "):] - // "pkg (oldver -> newver)" - parts := strings.SplitN(rest, " (", 2) - if len(parts) < 1 { - continue - } - name := strings.TrimSpace(parts[0]) - var ver string - if len(parts) == 2 { - // "oldver -> newver)" - verPart := strings.TrimSuffix(parts[1], ")") - arrow := strings.Split(verPart, " -> ") - if len(arrow) == 2 { - ver = strings.TrimSpace(arrow[1]) - } - } - pkgs = append(pkgs, snack.Package{ - Name: name, - Version: ver, - Installed: true, - }) - } - return pkgs -} - func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { upgrades, err := listUpgrades(ctx) if err != nil { diff --git a/apk/normalize.go b/apk/normalize.go new file mode 100644 index 0000000..1bfce06 --- /dev/null +++ b/apk/normalize.go @@ -0,0 +1,38 @@ +package apk + +import "strings" + +// normalizeName returns the canonical form of a package name. +// Alpine package names sometimes include version suffixes in queries. +// This strips common version patterns. +func normalizeName(name string) string { + n, _ := parseArchNormalize(name) + return n +} + +// parseArchNormalize extracts the architecture from a package name if present. +// Alpine package names typically don't embed architecture in the package name, +// but some query outputs may include it. Common patterns: +// - package-x86_64 +// - 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, + "loongarch64": true, + } + + if idx := strings.LastIndex(name, "-"); idx >= 0 { + suffix := name[idx+1:] + if knownArchs[suffix] { + return name[:idx], suffix + } + } + return name, "" +} diff --git a/apk/parse.go b/apk/parse.go index af6756a..7404248 100644 --- a/apk/parse.go +++ b/apk/parse.go @@ -136,3 +136,42 @@ func parseInfoNameVersion(output string) (string, string) { } return splitNameVersion(fields[0]) } + +// parseUpgradeSimulation parses `apk upgrade --simulate` output. +// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)" +func parseUpgradeSimulation(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, "Upgrading") { + continue + } + // "(1/3) Upgrading pkg (oldver -> newver)" + idx := strings.Index(line, "Upgrading ") + if idx < 0 { + continue + } + rest := line[idx+len("Upgrading "):] + // "pkg (oldver -> newver)" + parts := strings.SplitN(rest, " (", 2) + if len(parts) < 1 { + continue + } + name := strings.TrimSpace(parts[0]) + var ver string + if len(parts) == 2 { + // "oldver -> newver)" + verPart := strings.TrimSuffix(parts[1], ")") + arrow := strings.Split(verPart, " -> ") + if len(arrow) == 2 { + ver = strings.TrimSpace(arrow[1]) + } + } + pkgs = append(pkgs, snack.Package{ + Name: name, + Version: ver, + Installed: true, + }) + } + return pkgs +} diff --git a/aur/aur.go b/aur/aur.go index ab2162a..f32e0d3 100644 --- a/aur/aur.go +++ b/aur/aur.go @@ -1,10 +1,5 @@ -// 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 provides Go bindings for AUR (Arch User Repository) package building. +// AUR packages are built from source using makepkg. package aur import ( @@ -13,105 +8,91 @@ import ( "github.com/gogrlx/snack" ) -// AUR wraps the Arch User Repository using its RPC API and makepkg. +// AUR wraps makepkg and AUR helper tools for building packages from the AUR. type AUR struct { snack.Locker - - // BuildDir is the base directory for cloning and building packages. - // If empty, a temporary directory is created per build. - BuildDir string - - // MakepkgFlags are extra flags passed to makepkg (e.g. "--skippgpcheck"). - MakepkgFlags []string } -// New returns a new AUR manager with default settings. +// New returns a new AUR manager. func New() *AUR { return &AUR{} } -// Option configures an AUR manager. -type AUROption func(*AUR) - -// WithBuildDir sets a persistent build directory. -func WithBuildDir(dir string) AUROption { - return func(a *AUR) { a.BuildDir = dir } -} - -// WithMakepkgFlags sets extra flags for makepkg. -func WithMakepkgFlags(flags ...string) AUROption { - return func(a *AUR) { a.MakepkgFlags = flags } -} - -// NewWithOptions returns a new AUR manager with the given options. -func NewWithOptions(opts ...AUROption) *AUR { - a := New() - for _, opt := range opts { - opt(a) - } - return a -} +// 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) +) // Name returns "aur". func (a *AUR) Name() string { return "aur" } -// Available reports whether the AUR toolchain (git, makepkg, pacman) is present. +// Available reports whether makepkg is present on the system. func (a *AUR) Available() bool { return available() } -// Install clones, builds, and installs AUR packages. +// Install one or more packages from the AUR. func (a *AUR) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { a.Lock() defer a.Unlock() - return a.install(ctx, pkgs, opts...) + return install(ctx, pkgs, opts...) } -// Remove removes packages via pacman (AUR packages are regular pacman packages once installed). +// Remove is not directly supported by AUR (use pacman). func (a *AUR) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) { - a.Lock() - defer a.Unlock() return remove(ctx, pkgs, opts...) } -// Purge removes packages including config files via pacman. +// Purge is not directly supported by AUR (use pacman). func (a *AUR) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { - a.Lock() - defer a.Unlock() return purge(ctx, pkgs, opts...) } -// Upgrade rebuilds and reinstalls all foreign (AUR) packages. +// Upgrade all AUR packages (requires re-building from source). func (a *AUR) Upgrade(ctx context.Context, opts ...snack.Option) error { a.Lock() defer a.Unlock() - return a.upgradeAll(ctx, opts...) + return upgrade(ctx, opts...) } -// Update is a no-op for AUR (there is no local package index to refresh). -func (a *AUR) Update(_ context.Context) error { - return nil +// Update is a no-op for AUR (packages are fetched on demand). +func (a *AUR) Update(ctx context.Context) error { + return update(ctx) } -// List returns all installed foreign (non-repo) packages, which are typically AUR packages. +// List returns installed packages that came from the AUR. func (a *AUR) List(ctx context.Context) ([]snack.Package, error) { return list(ctx) } -// Search queries the AUR RPC API for packages matching the query. +// Search queries the AUR for packages matching the query. func (a *AUR) Search(ctx context.Context, query string) ([]snack.Package, error) { - return rpcSearch(ctx, query) + return search(ctx, query) } -// Info returns details about a specific AUR package from the RPC API. +// Info returns details about a specific AUR package. func (a *AUR) Info(ctx context.Context, pkg string) (*snack.Package, error) { - return rpcInfo(ctx, pkg) + return info(ctx, pkg) } -// IsInstalled reports whether a package is currently installed. +// IsInstalled reports whether a package from the AUR is currently installed. func (a *AUR) IsInstalled(ctx context.Context, pkg string) (bool, error) { return isInstalled(ctx, pkg) } -// Version returns the installed version of a package. +// Version returns the installed version of an AUR 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) +} diff --git a/aur/aur_linux.go b/aur/aur_linux.go index a1279a6..c974918 100644 --- a/aur/aur_linux.go +++ b/aur/aur_linux.go @@ -5,59 +5,68 @@ package aur import ( "bytes" "context" + "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" - "strconv" "strings" - git "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/transport" "github.com/gogrlx/snack" ) -const aurGitBase = "https://aur.archlinux.org" +const aurRPC = "https://aur.archlinux.org/rpc/v5" func available() bool { - for _, tool := range []string{"makepkg", "pacman"} { - if _, err := exec.LookPath(tool); err != nil { - return false - } - } - return true + _, err := exec.LookPath("makepkg") + return err == nil } -// runPacman executes a pacman command and returns stdout. -func runPacman(ctx context.Context, args []string, sudo bool) (string, error) { - cmd := "pacman" - if sudo { - args = append([]string{cmd}, args...) - cmd = "sudo" - } - c := exec.CommandContext(ctx, cmd, args...) - var stdout, stderr bytes.Buffer - c.Stdout = &stdout - c.Stderr = &stderr - err := c.Run() +// aurSearchResponse is the JSON response from the AUR RPC API. +type aurSearchResponse struct { + ResultCount int `json:"resultcount"` + Results []struct { + Name string `json:"Name"` + Version string `json:"Version"` + Description string `json:"Description"` + URL string `json:"URL"` + OutOfDate *int64 `json:"OutOfDate"` + Maintainer string `json:"Maintainer"` + Popularity float64 `json:"Popularity"` + } `json:"results"` +} + +func aurQuery(ctx context.Context, queryType, arg string) (*aurSearchResponse, error) { + url := fmt.Sprintf("%s/%s/%s", aurRPC, queryType, arg) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - se := stderr.String() - if strings.Contains(se, "permission denied") || strings.Contains(se, "requires root") { - return "", fmt.Errorf("aur: %w", snack.ErrPermissionDenied) - } - return "", fmt.Errorf("aur: %s: %w", strings.TrimSpace(se), err) + return nil, err } - return stdout.String(), nil + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("aur: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("aur: %w", err) + } + var result aurSearchResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("aur: %w", err) + } + return &result, nil } -// install clones PKGBUILDs from the AUR, builds with makepkg, and installs with pacman. -func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { o := snack.ApplyOptions(opts...) - var installed []snack.Package var unchanged []string for _, t := range pkgs { + // Check if already installed if !o.Reinstall && !o.DryRun { ok, err := isInstalled(ctx, t.Name) if err != nil { @@ -69,19 +78,8 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op } } - pkgFile, err := a.buildPackage(ctx, t) - if err != nil { - return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err) - } - - if o.DryRun { - installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"}) - continue - } - - args := []string{"-U", "--noconfirm", pkgFile} - if _, err := runPacman(ctx, args, o.Sudo); err != nil { - return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err) + if err := installPkg(ctx, t.Name, o); err != nil { + return snack.InstallResult{}, err } v, _ := version(ctx, t.Name) @@ -92,315 +90,92 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op Installed: true, }) } - return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil } -// buildPackage clones the AUR git repo for a package and runs makepkg. -// Returns the path to the built .pkg.tar.zst file. -func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error) { - // Determine build directory - buildDir := a.BuildDir - if buildDir == "" { - tmp, err := os.MkdirTemp("", "snack-aur-*") - if err != nil { - return "", fmt.Errorf("creating temp dir: %w", err) - } - buildDir = tmp - } - - pkgDir := filepath.Join(buildDir, t.Name) - - // Clone or update the PKGBUILD repo - if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil { - return "", err - } - - // Run makepkg - args := []string{"-s", "-f", "--noconfirm"} - args = append(args, a.MakepkgFlags...) - c := exec.CommandContext(ctx, "makepkg", args...) - c.Dir = pkgDir - var stderr bytes.Buffer - c.Stderr = &stderr - c.Stdout = &stderr // makepkg output goes to stderr anyway - if err := c.Run(); err != nil { - return "", fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err) - } - - // Find the built package file - matches, err := filepath.Glob(filepath.Join(pkgDir, "*.pkg.tar*")) - if err != nil || len(matches) == 0 { - return "", fmt.Errorf("makepkg %s: no package file produced", t.Name) - } - return matches[len(matches)-1], nil -} - -// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does. -func cloneOrPull(ctx context.Context, pkg, dir string) error { - repoURL := aurGitBase + "/" + pkg + ".git" - - if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { - // Repo exists, pull latest - r, err := git.PlainOpen(dir) - if err != nil { - return fmt.Errorf("aur open %s: %w", pkg, err) - } - w, err := r.Worktree() - if err != nil { - return fmt.Errorf("aur worktree %s: %w", pkg, err) - } - if err := w.Pull(&git.PullOptions{}); err != nil && err != git.NoErrAlreadyUpToDate { - return fmt.Errorf("aur pull %s: %w", pkg, err) - } - return nil - } - - // Clone fresh (depth 1) - _, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ - URL: repoURL, - Depth: 1, - }) +func installPkg(ctx context.Context, pkg string, opts snack.Options) error { + tmpDir, err := os.MkdirTemp("", "aur-"+pkg) if err != nil { - if err == transport.ErrRepositoryNotFound { - return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound) + return fmt.Errorf("aur: create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + gitURL := fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg) + cloneCmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", gitURL, tmpDir) + var stderr bytes.Buffer + cloneCmd.Stderr = &stderr + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("aur: git clone %s: %s: %w", pkg, stderr.String(), err) + } + + makepkgArgs := []string{"-si", "--noconfirm"} + if opts.AssumeYes { + makepkgArgs = append(makepkgArgs, "--noconfirm") + } + + makeCmd := exec.CommandContext(ctx, "makepkg", makepkgArgs...) + makeCmd.Dir = tmpDir + makeCmd.Stderr = &stderr + makeCmd.Stdout = os.Stdout + + if err := makeCmd.Run(); err != nil { + se := stderr.String() + if strings.Contains(se, "permission denied") { + return fmt.Errorf("aur: %w", snack.ErrPermissionDenied) } - return fmt.Errorf("aur clone %s: %w", pkg, err) + return fmt.Errorf("aur: makepkg %s: %s: %w", pkg, se, err) } return nil } -func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) { - o := snack.ApplyOptions(opts...) - - var toRemove []snack.Target - var unchanged []string - for _, t := range pkgs { - ok, err := isInstalled(ctx, t.Name) - if err != nil { - return snack.RemoveResult{}, err - } - if !ok { - unchanged = append(unchanged, t.Name) - } else { - toRemove = append(toRemove, t) - } - } - - if len(toRemove) > 0 { - args := append([]string{"-R", "--noconfirm"}, snack.TargetNames(toRemove)...) - if _, err := runPacman(ctx, args, o.Sudo); err != nil { - return snack.RemoveResult{}, err - } - } - - var removed []snack.Package - for _, t := range toRemove { - removed = append(removed, snack.Package{Name: t.Name}) - } - return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) { + return snack.RemoveResult{}, fmt.Errorf("aur: remove not supported, use pacman instead") } -func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { - o := snack.ApplyOptions(opts...) - args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...) - _, err := runPacman(ctx, args, o.Sudo) - return err +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { + return fmt.Errorf("aur: purge not supported, use pacman instead") } -// upgradeAll rebuilds all installed foreign packages that have newer versions in the AUR. -func (a *AUR) upgradeAll(ctx context.Context, opts ...snack.Option) error { - upgrades, err := listUpgrades(ctx) +func upgrade(ctx context.Context, opts ...snack.Option) error { + aurPkgs, err := list(ctx) if err != nil { return err } - if len(upgrades) == 0 { - return nil - } - - targets := make([]snack.Target, len(upgrades)) - for i, p := range upgrades { - targets[i] = snack.Target{Name: p.Name} - } - - // Force reinstall since we're upgrading - allOpts := append([]snack.Option{snack.WithReinstall()}, opts...) - _, err = a.install(ctx, targets, allOpts...) - return err -} - -func list(ctx context.Context) ([]snack.Package, error) { - // pacman -Qm lists foreign (non-repo) packages, which are typically AUR - out, err := runPacman(ctx, []string{"-Qm"}, false) - if err != nil { - // exit status 1 means no foreign packages - if strings.Contains(err.Error(), "exit status 1") { - return nil, nil - } - return nil, fmt.Errorf("aur list: %w", err) - } - return parsePackageList(out), nil -} - -func isInstalled(ctx context.Context, pkg string) (bool, error) { - c := exec.CommandContext(ctx, "pacman", "-Q", pkg) - err := c.Run() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return false, nil - } - return false, fmt.Errorf("aur isInstalled: %w", err) - } - return true, nil -} - -func version(ctx context.Context, pkg string) (string, error) { - out, err := runPacman(ctx, []string{"-Q", pkg}, false) - if err != nil { - if strings.Contains(err.Error(), "exit status 1") { - return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled) - } - return "", fmt.Errorf("aur version: %w", err) - } - parts := strings.Fields(strings.TrimSpace(out)) - if len(parts) < 2 { - return "", fmt.Errorf("aur version %s: unexpected output %q", pkg, out) - } - return parts[1], nil -} - -func latestVersion(ctx context.Context, pkg string) (string, error) { - p, err := rpcInfo(ctx, pkg) - if err != nil { - return "", err - } - return p.Version, nil -} - -func listUpgrades(ctx context.Context) ([]snack.Package, error) { - // Get all installed foreign packages - installed, err := list(ctx) - if err != nil { - return nil, err - } - if len(installed) == 0 { - return nil, nil - } - - // Batch-query the AUR for all of them - names := make([]string, len(installed)) - for i, p := range installed { - names[i] = p.Name - } - aurInfo, err := rpcInfoMulti(ctx, names) - if err != nil { - return nil, err - } - - // Compare versions - var upgrades []snack.Package - for _, inst := range installed { - aurPkg, ok := aurInfo[inst.Name] - if !ok { - continue // not in AUR (maybe from a custom repo) - } - cmp, err := versionCmp(ctx, inst.Version, aurPkg.Version) + for _, p := range aurPkgs { + result, err := aurQuery(ctx, "info", p.Name) if err != nil { - continue // skip packages where vercmp fails + continue } - if cmp < 0 { - upgrades = append(upgrades, snack.Package{ - Name: inst.Name, - Version: aurPkg.Version, - Repository: "aur", - Installed: true, - }) + if result.ResultCount == 0 { + continue } - } - return upgrades, nil -} - -func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { - inst, err := version(ctx, pkg) - if err != nil { - return false, err - } - latest, err := latestVersion(ctx, pkg) - if err != nil { - return false, err - } - cmp, err := versionCmp(ctx, inst, latest) - if err != nil { - return false, err - } - return cmp < 0, nil -} - -func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) { - c := exec.CommandContext(ctx, "vercmp", ver1, ver2) - out, err := c.Output() - if err != nil { - return 0, fmt.Errorf("vercmp: %w", err) - } - n, err := strconv.Atoi(strings.TrimSpace(string(out))) - if err != nil { - return 0, fmt.Errorf("vercmp: unexpected output %q: %w", string(out), err) - } - switch { - case n < 0: - return -1, nil - case n > 0: - return 1, nil - default: - return 0, nil - } -} - -func autoremove(ctx context.Context, opts ...snack.Option) error { - o := snack.ApplyOptions(opts...) - - // Get orphans - orphans, err := runPacman(ctx, []string{"-Qdtq"}, false) - if err != nil { - if strings.Contains(err.Error(), "exit status 1") { - return nil // no orphans - } - return fmt.Errorf("aur autoremove: %w", err) - } - orphans = strings.TrimSpace(orphans) - if orphans == "" { - return nil - } - - pkgs := strings.Fields(orphans) - args := append([]string{"-Rns", "--noconfirm"}, pkgs...) - _, err = runPacman(ctx, args, o.Sudo) - return err -} - -// cleanBuildDir removes all subdirectories in the build directory. -func (a *AUR) cleanBuildDir() error { - if a.BuildDir == "" { - return nil // temp dirs are cleaned automatically - } - entries, err := os.ReadDir(a.BuildDir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("aur clean: %w", err) - } - for _, e := range entries { - if e.IsDir() { - if err := os.RemoveAll(filepath.Join(a.BuildDir, e.Name())); err != nil { - return fmt.Errorf("aur clean %s: %w", e.Name(), err) + if result.Results[0].Version != p.Version { + if _, err := install(ctx, []snack.Target{{Name: p.Name}}, opts...); err != nil { + return err } } } return nil } -// parsePackageList parses "name version" lines from pacman -Q output. +func update(_ context.Context) error { + return nil +} + +func list(ctx context.Context) ([]snack.Package, error) { + c := exec.CommandContext(ctx, "pacman", "-Qm") + var stdout bytes.Buffer + c.Stdout = &stdout + if err := c.Run(); err != nil { + // exit status 1 means no foreign packages + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil, nil + } + return nil, fmt.Errorf("aur list: %w", err) + } + return parsePackageList(stdout.String()), nil +} + func parsePackageList(output string) []snack.Package { var pkgs []snack.Package for _, line := range strings.Split(output, "\n") { @@ -421,3 +196,198 @@ func parsePackageList(output string) []snack.Package { } return pkgs } + +func search(ctx context.Context, query string) ([]snack.Package, error) { + result, err := aurQuery(ctx, "search", query) + if err != nil { + return nil, err + } + var pkgs []snack.Package + for _, r := range result.Results { + pkgs = append(pkgs, snack.Package{ + Name: r.Name, + Version: r.Version, + Description: r.Description, + }) + } + return pkgs, nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + result, err := aurQuery(ctx, "info", pkg) + if err != nil { + return nil, err + } + if result.ResultCount == 0 { + return nil, fmt.Errorf("aur info %s: %w", pkg, snack.ErrNotFound) + } + r := result.Results[0] + return &snack.Package{ + Name: r.Name, + Version: r.Version, + Description: r.Description, + }, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "pacman", "-Q", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("aur isInstalled: %w", err) + } + aurPkgs, err := list(ctx) + if err != nil { + return false, err + } + for _, p := range aurPkgs { + if p.Name == pkg { + return true, nil + } + } + return false, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + c := exec.CommandContext(ctx, "pacman", "-Q", pkg) + var stdout bytes.Buffer + c.Stdout = &stdout + if err := c.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("aur version: %w", err) + } + parts := strings.Fields(strings.TrimSpace(stdout.String())) + if len(parts) < 2 { + return "", fmt.Errorf("aur version %s: unexpected output", pkg) + } + return parts[1], nil +} + +func latestVersion(ctx context.Context, pkg string) (string, error) { + result, err := aurQuery(ctx, "info", pkg) + if err != nil { + return "", err + } + if result.ResultCount == 0 { + return "", fmt.Errorf("aur latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return result.Results[0].Version, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + aurPkgs, err := list(ctx) + if err != nil { + return nil, err + } + var upgrades []snack.Package + for _, p := range aurPkgs { + result, err := aurQuery(ctx, "info", p.Name) + if err != nil || result.ResultCount == 0 { + continue + } + if result.Results[0].Version != p.Version { + upgrades = append(upgrades, snack.Package{ + Name: p.Name, + Version: result.Results[0].Version, + Repository: "aur", + Installed: true, + }) + } + } + return upgrades, nil +} + +func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { + installed, err := version(ctx, pkg) + if err != nil { + return false, err + } + latest, err := latestVersion(ctx, pkg) + if err != nil { + return false, err + } + cmp, err := versionCmp(ctx, installed, latest) + if err != nil { + return false, err + } + return cmp < 0, nil +} + +func versionCmp(_ context.Context, ver1, ver2 string) (int, error) { + c := exec.Command("vercmp", ver1, ver2) + var stdout bytes.Buffer + c.Stdout = &stdout + if err := c.Run(); err != nil { + return 0, fmt.Errorf("aur versionCmp: %w", err) + } + result := strings.TrimSpace(stdout.String()) + switch result { + case "-1": + return -1, nil + case "0": + return 0, nil + case "1": + return 1, nil + default: + return 0, fmt.Errorf("aur versionCmp: unexpected output %q", result) + } +} + +func autoremove(ctx context.Context, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + + // Get orphans via pacman + c := exec.CommandContext(ctx, "pacman", "-Qdtq") + var stdout bytes.Buffer + c.Stdout = &stdout + if err := c.Run(); err != nil { + // exit status 1 means no orphans + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil + } + return fmt.Errorf("aur autoremove: %w", err) + } + + orphans := strings.TrimSpace(stdout.String()) + if orphans == "" { + return nil + } + + args := []string{"-Rns", "--noconfirm"} + args = append(args, strings.Fields(orphans)...) + cmd := "pacman" + if o.Sudo { + args = append([]string{cmd}, args...) + cmd = "sudo" + } + + removeCmd := exec.CommandContext(ctx, cmd, args...) + return removeCmd.Run() +} + +func clean(_ context.Context) error { + // AUR builds in temp dirs, nothing persistent to clean + return nil +} + +func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + // For AUR, upgrading is just reinstalling from source + allOpts := append([]snack.Option{snack.WithReinstall()}, opts...) + return install(ctx, pkgs, allOpts...) +} + +// getAURBuildDir returns the directory to use for AUR builds. +func getAURBuildDir() string { + if dir := os.Getenv("AURDEST"); dir != "" { + return dir + } + if cache := os.Getenv("XDG_CACHE_HOME"); cache != "" { + return filepath.Join(cache, "aur") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "aur") +} diff --git a/aur/aur_other.go b/aur/aur_other.go index 420c43b..6b73203 100644 --- a/aur/aur_other.go +++ b/aur/aur_other.go @@ -10,7 +10,7 @@ import ( func available() bool { return false } -func (a *AUR) install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { return snack.InstallResult{}, snack.ErrUnsupportedPlatform } @@ -22,7 +22,11 @@ func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func (a *AUR) upgradeAll(_ context.Context, _ ...snack.Option) error { +func upgrade(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func update(_ context.Context) error { return snack.ErrUnsupportedPlatform } @@ -30,6 +34,14 @@ 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 } @@ -58,6 +70,10 @@ func autoremove(_ context.Context, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func (a *AUR) cleanBuildDir() error { +func clean(_ context.Context) error { return snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/aur/aur_test.go b/aur/aur_test.go index 803c056..6826a6d 100644 --- a/aur/aur_test.go +++ b/aur/aur_test.go @@ -4,99 +4,74 @@ import ( "testing" "github.com/gogrlx/snack" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestParsePackageList(t *testing.T) { +// 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) { tests := []struct { - name string - input string - expect int + input string + want string }{ - { - 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, - }, + {"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.name, func(t *testing.T) { - pkgs := parsePackageList(tt.input) - assert.Len(t, pkgs, tt.expect) - for _, p := range pkgs { - assert.NotEmpty(t, p.Name) - assert.NotEmpty(t, p.Version) - assert.Equal(t, "aur", p.Repository) - assert.True(t, p.Installed) + 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 TestNew(t *testing.T) { +func TestParseArch(t *testing.T) { + tests := []struct { + input string + wantName string + wantArch string + }{ + {"yay", "yay", ""}, + {"paru", "paru", ""}, + {"google-chrome", "google-chrome", ""}, + } + 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) -} - -func TestInterfaceCompliance(t *testing.T) { - var _ snack.Manager = (*AUR)(nil) - var _ snack.VersionQuerier = (*AUR)(nil) - var _ snack.Cleaner = (*AUR)(nil) - var _ snack.PackageUpgrader = (*AUR)(nil) -} - -func TestInterfaceNonCompliance(t *testing.T) { - a := New() - var m snack.Manager = a - - if _, ok := m.(snack.FileOwner); ok { - t.Error("AUR should not implement FileOwner") - } - if _, ok := m.(snack.Holder); ok { - t.Error("AUR should not implement Holder") - } - 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") - } - if _, ok := m.(snack.NameNormalizer); ok { - t.Error("AUR should not implement NameNormalizer") - } - if _, ok := m.(snack.DryRunner); ok { - t.Error("AUR should not implement DryRunner") + 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) + } + }) } } @@ -110,14 +85,15 @@ func TestCapabilities(t *testing.T) { }{ {"VersionQuery", caps.VersionQuery, true}, {"Clean", caps.Clean, true}, - {"FileOwnership", caps.FileOwnership, false}, + {"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}, - {"NameNormalize", caps.NameNormalize, false}, {"DryRun", caps.DryRun, false}, - {"PackageUpgrade", caps.PackageUpgrade, true}, } for _, tt := range tests { @@ -129,78 +105,21 @@ func TestCapabilities(t *testing.T) { } } -func TestName(t *testing.T) { - a := New() - assert.Equal(t, "aur", a.Name()) -} - -func TestParsePackageList_EdgeCases(t *testing.T) { - tests := []struct { - name string - input string - wantLen int - wantNames []string - wantVers []string - }{ - { - name: "empty string", - input: "", - wantLen: 0, - }, - { - name: "whitespace only", - input: " \n\t\n \n", - wantLen: 0, - }, - { - name: "single package", - input: "yay 12.5.7-1\n", - wantLen: 1, - wantNames: []string{"yay"}, - wantVers: []string{"12.5.7-1"}, - }, - { - name: "malformed single field", - input: "orphan\n", - wantLen: 0, - }, - { - name: "malformed mixed with valid", - input: "orphan\nyay 12.5.7-1\nbadline\nparu 2.0-1\n", - wantLen: 2, - wantNames: []string{"yay", "paru"}, - wantVers: []string{"12.5.7-1", "2.0-1"}, - }, - { - name: "extra fields ignored", - input: "yay 12.5.7-1 extra stuff\n", - wantLen: 1, - wantNames: []string{"yay"}, - wantVers: []string{"12.5.7-1"}, - }, - { - name: "trailing and leading whitespace on lines", - input: " yay 12.5.7-1 \n paru 2.0.4-1\n\n", - wantLen: 2, - wantNames: []string{"yay", "paru"}, - wantVers: []string{"12.5.7-1", "2.0.4-1"}, - }, +func TestInterfaceNonCompliance(t *testing.T) { + var m snack.Manager = New() + if _, ok := m.(snack.Holder); ok { + t.Error("AUR should not implement Holder") } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pkgs := parsePackageList(tt.input) - require.Len(t, pkgs, tt.wantLen) - for i, p := range pkgs { - assert.Equal(t, "aur", p.Repository, "all packages should have Repository=aur") - assert.True(t, p.Installed, "all packages should have Installed=true") - if i < len(tt.wantNames) { - assert.Equal(t, tt.wantNames[i], p.Name) - } - if i < len(tt.wantVers) { - assert.Equal(t, tt.wantVers[i], p.Version) - } - } - }) + 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") } } diff --git a/aur/capabilities.go b/aur/capabilities.go index 14c5ce1..ec51d82 100644 --- a/aur/capabilities.go +++ b/aur/capabilities.go @@ -6,30 +6,22 @@ import ( "github.com/gogrlx/snack" ) -// Compile-time interface checks. -var ( - _ snack.Manager = (*AUR)(nil) - _ snack.VersionQuerier = (*AUR)(nil) - _ snack.Cleaner = (*AUR)(nil) - _ snack.PackageUpgrader = (*AUR)(nil) -) - -// LatestVersion returns the latest version available in the AUR. +// LatestVersion returns the latest version of an AUR package. func (a *AUR) LatestVersion(ctx context.Context, pkg string) (string, error) { return latestVersion(ctx, pkg) } -// ListUpgrades returns installed foreign packages that have newer versions in the AUR. +// ListUpgrades returns AUR packages that have newer versions available. func (a *AUR) ListUpgrades(ctx context.Context) ([]snack.Package, error) { return listUpgrades(ctx) } -// UpgradeAvailable reports whether a newer version is available in the AUR. +// UpgradeAvailable reports whether a newer version is available. func (a *AUR) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { return upgradeAvailable(ctx, pkg) } -// VersionCmp compares two version strings using pacman's vercmp. +// VersionCmp compares two version strings using vercmp. func (a *AUR) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { return versionCmp(ctx, ver1, ver2) } @@ -41,14 +33,14 @@ func (a *AUR) Autoremove(ctx context.Context, opts ...snack.Option) error { return autoremove(ctx, opts...) } -// Clean removes cached build artifacts from the build directory. +// Clean is a no-op for AUR (builds use temp directories). func (a *AUR) Clean(_ context.Context) error { - return a.cleanBuildDir() + return nil } // UpgradePackages rebuilds and reinstalls specific AUR packages. func (a *AUR) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { a.Lock() defer a.Unlock() - return a.install(ctx, pkgs, opts...) + return upgradePackages(ctx, pkgs, opts...) } diff --git a/aur/normalize.go b/aur/normalize.go new file mode 100644 index 0000000..3ae3dd1 --- /dev/null +++ b/aur/normalize.go @@ -0,0 +1,15 @@ +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, "" +} diff --git a/brew/brew.go b/brew/brew.go new file mode 100644 index 0000000..2c5a0ce --- /dev/null +++ b/brew/brew.go @@ -0,0 +1,156 @@ +// Package brew provides Go bindings for Homebrew package manager. +package brew + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Brew wraps the brew CLI. +type Brew struct { + snack.Locker +} + +// New returns a new Brew manager. +func New() *Brew { + return &Brew{} +} + +// Compile-time interface checks. +var ( + _ snack.Manager = (*Brew)(nil) + _ snack.VersionQuerier = (*Brew)(nil) + _ snack.Cleaner = (*Brew)(nil) + _ snack.FileOwner = (*Brew)(nil) + _ snack.NameNormalizer = (*Brew)(nil) + _ snack.PackageUpgrader = (*Brew)(nil) +) + +// Name returns "brew". +func (b *Brew) Name() string { return "brew" } + +// Available reports whether brew is present on the system. +func (b *Brew) Available() bool { return available() } + +// Install one or more packages. +func (b *Brew) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + b.Lock() + defer b.Unlock() + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (b *Brew) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) { + b.Lock() + defer b.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages including all dependencies. +func (b *Brew) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + b.Lock() + defer b.Unlock() + return purge(ctx, pkgs, opts...) +} + +// Upgrade all installed packages. +func (b *Brew) Upgrade(ctx context.Context, opts ...snack.Option) error { + b.Lock() + defer b.Unlock() + return upgrade(ctx, opts...) +} + +// Update refreshes the package index. +func (b *Brew) Update(ctx context.Context) error { + b.Lock() + defer b.Unlock() + return update(ctx) +} + +// List returns all installed packages. +func (b *Brew) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries Homebrew for packages. +func (b *Brew) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (b *Brew) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (b *Brew) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (b *Brew) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// NormalizeName returns the canonical form of a package name. +func (b *Brew) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +// Homebrew does not embed architecture in package names. +func (b *Brew) ParseArch(name string) (string, string) { + return name, "" +} + +// LatestVersion returns the latest available version of a package. +func (b *Brew) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns packages that have newer versions available. +func (b *Brew) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (b *Brew) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings. +func (b *Brew) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + +// Autoremove removes packages that were installed as dependencies but are no longer needed. +func (b *Brew) Autoremove(ctx context.Context, opts ...snack.Option) error { + b.Lock() + defer b.Unlock() + return autoremove(ctx, opts...) +} + +// Clean removes cached package files. +func (b *Brew) Clean(ctx context.Context) error { + b.Lock() + defer b.Unlock() + return clean(ctx) +} + +// FileList returns all files installed by a package. +func (b *Brew) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (b *Brew) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} + +// UpgradePackages upgrades specific installed packages. +func (b *Brew) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + b.Lock() + defer b.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/brew/brew_other.go b/brew/brew_other.go new file mode 100644 index 0000000..6b130ee --- /dev/null +++ b/brew/brew_other.go @@ -0,0 +1,87 @@ +//go:build !darwin && !linux + +package brew + +import ( + "context" + + "github.com/gogrlx/snack" +) + +func available() bool { return false } + +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} + +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) { + return snack.RemoveResult{}, snack.ErrUnsupportedPlatform +} + +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func upgrade(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func update(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +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 +} + +func version(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func latestVersion(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func listUpgrades(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func upgradeAvailable(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} + +func versionCmp(_ context.Context, _, _ string) (int, error) { + return 0, snack.ErrUnsupportedPlatform +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func 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 +} diff --git a/brew/brew_test.go b/brew/brew_test.go new file mode 100644 index 0000000..a8cb341 --- /dev/null +++ b/brew/brew_test.go @@ -0,0 +1,223 @@ +//go:build darwin || linux + +package brew + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks +var ( + _ snack.Manager = (*Brew)(nil) + _ snack.VersionQuerier = (*Brew)(nil) + _ snack.Cleaner = (*Brew)(nil) + _ snack.FileOwner = (*Brew)(nil) + _ snack.NameNormalizer = (*Brew)(nil) +) + +func TestNew(t *testing.T) { + b := New() + if b == nil { + t.Fatal("New() returned nil") + } +} + +func TestName(t *testing.T) { + b := New() + if b.Name() != "brew" { + t.Errorf("Name() = %q, want %q", b.Name(), "brew") + } +} + +func TestParseBrewList(t *testing.T) { + input := `git 2.43.0 +go 1.21.6 +vim 9.1.0 +` + pkgs := parseBrewList(input) + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "git" || pkgs[0].Version != "2.43.0" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseBrewList_Empty(t *testing.T) { + pkgs := parseBrewList("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseBrewList_SinglePackage(t *testing.T) { + input := "curl 8.6.0\n" + pkgs := parseBrewList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "curl" { + t.Errorf("expected name=curl, got %q", pkgs[0].Name) + } +} + +func TestParseBrewSearch(t *testing.T) { + input := `==> Formulae +git +git-absorb +git-annex +==> Casks +git-credential-manager +` + pkgs := parseBrewSearch(input) + if len(pkgs) != 4 { + t.Fatalf("expected 4 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "git" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } +} + +func TestParseBrewSearch_Empty(t *testing.T) { + pkgs := parseBrewSearch("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseBrewInfo(t *testing.T) { + input := `{"formulae":[{"name":"git","full_name":"git","desc":"Distributed revision control system","versions":{"stable":"2.43.0"},"installed":[{"version":"2.43.0"}]}],"casks":[]}` + pkg := parseBrewInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Name != "git" { + t.Errorf("expected name 'git', got %q", pkg.Name) + } + if pkg.Version != "2.43.0" { + t.Errorf("unexpected version: %q", pkg.Version) + } + if !pkg.Installed { + t.Error("expected Installed=true") + } +} + +func TestParseBrewInfo_NotInstalled(t *testing.T) { + input := `{"formulae":[{"name":"wget","full_name":"wget","desc":"Internet file retriever","versions":{"stable":"1.21"},"installed":[]}],"casks":[]}` + pkg := parseBrewInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Installed { + t.Error("expected Installed=false") + } +} + +func TestParseBrewInfo_Cask(t *testing.T) { + input := `{"formulae":[],"casks":[{"token":"visual-studio-code","name":["Visual Studio Code"],"desc":"Open-source code editor","version":"1.85.0"}]}` + pkg := parseBrewInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Name != "visual-studio-code" { + t.Errorf("expected name 'visual-studio-code', got %q", pkg.Name) + } +} + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"git", "git"}, + {"python@3.12", "python"}, + {"node@18", "node"}, + {"go", "go"}, + {"ruby@3.2", "ruby"}, + } + + b := New() + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := b.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 + }{ + {"git", "git", ""}, + // Homebrew doesn't embed arch in names - @ is version suffix + {"python@3.12", "python@3.12", ""}, + {"node@18", "node@18", ""}, + } + + b := New() + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + gotName, gotArch := b.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}, + {"FileOwnership", caps.FileOwnership, true}, + {"NameNormalize", caps.NameNormalize, true}, + // Homebrew does not support these + {"Hold", caps.Hold, false}, + {"RepoManagement", caps.RepoManagement, false}, + {"KeyManagement", caps.KeyManagement, false}, + {"Groups", caps.Groups, false}, + {"DryRun", caps.DryRun, false}, + {"PackageUpgrade", caps.PackageUpgrade, true}, + } + + 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) + } + }) + } +} + +func TestInterfaceNonCompliance(t *testing.T) { + var m snack.Manager = New() + if _, ok := m.(snack.Holder); ok { + t.Error("Brew should not implement Holder") + } + if _, ok := m.(snack.RepoManager); ok { + t.Error("Brew should not implement RepoManager") + } + if _, ok := m.(snack.KeyManager); ok { + t.Error("Brew should not implement KeyManager") + } + if _, ok := m.(snack.Grouper); ok { + t.Error("Brew should not implement Grouper") + } +} diff --git a/brew/brew_unix.go b/brew/brew_unix.go new file mode 100644 index 0000000..1ad275f --- /dev/null +++ b/brew/brew_unix.go @@ -0,0 +1,473 @@ +//go:build darwin || linux + +package brew + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("brew") + return err == nil +} + +func run(ctx context.Context, args []string) (string, error) { + c := exec.CommandContext(ctx, "brew", 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") { + return "", fmt.Errorf("brew: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("brew: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + o := snack.ApplyOptions(opts...) + var toInstall []snack.Target + var unchanged []string + for _, t := range pkgs { + if o.Reinstall || t.Version != "" || o.DryRun { + toInstall = append(toInstall, t) + continue + } + ok, err := isInstalled(ctx, t.Name) + if err != nil { + return snack.InstallResult{}, err + } + if ok { + unchanged = append(unchanged, t.Name) + } else { + toInstall = append(toInstall, t) + } + } + for _, t := range toInstall { + args := []string{"install"} + pkg := t.Name + if t.Version != "" { + pkg = t.Name + "@" + t.Version + } + args = append(args, pkg) + if _, err := run(ctx, args); err != nil { + return snack.InstallResult{}, err + } + } + var installed []snack.Package + for _, t := range toInstall { + v, _ := version(ctx, t.Name) + installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true}) + } + return snack.InstallResult{Installed: installed, Unchanged: unchanged}, 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 { + if o.DryRun { + toRemove = append(toRemove, t) + continue + } + 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{"uninstall"}, snack.TargetNames(toRemove)...) + if _, err := run(ctx, args); 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, _ ...snack.Option) error { + args := append([]string{"uninstall", "--zap"}, snack.TargetNames(pkgs)...) + _, err := run(ctx, args) + return err +} + +func upgrade(ctx context.Context, _ ...snack.Option) error { + _, err := run(ctx, []string{"upgrade"}) + return err +} + +func update(ctx context.Context) error { + _, err := run(ctx, []string{"update"}) + return err +} + +func list(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"list", "--versions"}) + if err != nil { + return nil, fmt.Errorf("brew list: %w", err) + } + return parseBrewList(out), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + out, err := run(ctx, []string{"search", query}) + if err != nil { + if strings.Contains(err.Error(), "No formulae or casks found") { + return nil, nil + } + return nil, fmt.Errorf("brew search: %w", err) + } + return parseBrewSearch(out), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + out, err := run(ctx, []string{"info", "--json=v2", pkg}) + if err != nil { + if strings.Contains(err.Error(), "No available formula") || + strings.Contains(err.Error(), "No formulae or casks found") { + return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound) + } + return nil, fmt.Errorf("brew info: %w", err) + } + p := parseBrewInfo(out) + if p == nil { + return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound) + } + return p, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "brew", "list", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("brew isInstalled: %w", err) + } + return true, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"list", "--versions", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("brew version: %w", err) + } + pkgs := parseBrewList(out) + if len(pkgs) == 0 { + return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled) + } + return pkgs[0].Version, nil +} + +func latestVersion(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"info", "--json=v2", pkg}) + if err != nil { + if strings.Contains(err.Error(), "No available formula") { + return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return "", fmt.Errorf("brew latestVersion: %w", err) + } + ver := parseBrewInfoVersion(out) + if ver == "" { + return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return ver, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"outdated", "--json=v2"}) + if err != nil { + return nil, fmt.Errorf("brew listUpgrades: %w", err) + } + return parseBrewOutdated(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 { + return true, nil + } + } + return false, nil +} + +func versionCmp(_ context.Context, ver1, ver2 string) (int, error) { + return semverCmp(ver1, ver2), nil +} + +func autoremove(ctx context.Context, _ ...snack.Option) error { + _, err := run(ctx, []string{"autoremove"}) + return err +} + +func clean(ctx context.Context) error { + _, err := run(ctx, []string{"cleanup"}) + return err +} + +// 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 { + Stable string `json:"stable"` + } `json:"versions"` + Installed []struct { + Version string `json:"version"` + } `json:"installed"` + } `json:"formulae"` + Casks []struct { + Token string `json:"token"` + Name []string `json:"name"` + 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"` + InstalledVersions []string `json:"installed_versions"` + CurrentVersion string `json:"current_version"` + } `json:"formulae"` + Casks []struct { + Name string `json:"name"` + InstalledVersions string `json:"installed_versions"` + CurrentVersion string `json:"current_version"` + } `json:"casks"` +} + +func parseBrewList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 1 { + continue + } + pkg := snack.Package{ + Name: fields[0], + Installed: true, + } + if len(fields) >= 2 { + pkg.Version = fields[1] + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +func parseBrewSearch(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "==>") { + continue + } + for _, name := range strings.Fields(line) { + pkgs = append(pkgs, snack.Package{Name: name}) + } + } + return pkgs +} + +func parseBrewInfo(output string) *snack.Package { + var data brewInfoJSON + if err := json.Unmarshal([]byte(output), &data); err != nil { + return nil + } + if len(data.Formulae) > 0 { + f := data.Formulae[0] + pkg := &snack.Package{ + Name: f.Name, + Version: f.Versions.Stable, + Description: f.Desc, + } + if len(f.Installed) > 0 { + pkg.Installed = true + pkg.Version = f.Installed[0].Version + } + return pkg + } + if len(data.Casks) > 0 { + c := data.Casks[0] + return &snack.Package{ + Name: c.Token, + Version: c.Version, + Description: c.Desc, + } + } + return nil +} + +func parseBrewInfoVersion(output string) string { + var data brewInfoJSON + if err := json.Unmarshal([]byte(output), &data); err != nil { + return "" + } + if len(data.Formulae) > 0 { + return data.Formulae[0].Versions.Stable + } + if len(data.Casks) > 0 { + return data.Casks[0].Version + } + return "" +} + +func parseBrewOutdated(output string) []snack.Package { + var data brewOutdatedJSON + if err := json.Unmarshal([]byte(output), &data); err != nil { + return nil + } + var pkgs []snack.Package + for _, f := range data.Formulae { + pkgs = append(pkgs, snack.Package{ + Name: f.Name, + Version: f.CurrentVersion, + Installed: true, + }) + } + for _, c := range data.Casks { + pkgs = append(pkgs, snack.Package{ + Name: c.Name, + Version: c.CurrentVersion, + Installed: true, + }) + } + return pkgs +} + +func semverCmp(a, b string) int { + partsA := strings.Split(a, ".") + partsB := strings.Split(b, ".") + + maxLen := len(partsA) + if len(partsB) > maxLen { + maxLen = len(partsB) + } + + for i := 0; i < maxLen; i++ { + var numA, numB int + if i < len(partsA) { + fmt.Sscanf(partsA[i], "%d", &numA) + } + if i < len(partsB) { + fmt.Sscanf(partsB[i], "%d", &numB) + } + if numA < numB { + return -1 + } + if numA > numB { + return 1 + } + } + return 0 +} + +func fileList(ctx context.Context, pkg string) ([]string, error) { + out, err := run(ctx, []string{"list", "--formula", pkg}) + if err != nil { + // Try cask + out, err = run(ctx, []string{"list", "--cask", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("brew fileList %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("brew fileList: %w", err) + } + } + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + // brew doesn't have a direct "which package owns this file" command + // We need to iterate through installed packages and check their files + out, err := run(ctx, []string{"list", "--formula"}) + if err != nil { + return "", fmt.Errorf("brew owner: %w", err) + } + for _, pkg := range strings.Fields(out) { + files, err := fileList(ctx, pkg) + if err != nil { + continue + } + for _, f := range files { + if f == path || strings.HasSuffix(f, "/"+path) { + return pkg, nil + } + } + } + return "", fmt.Errorf("brew owner %s: %w", path, snack.ErrNotFound) +} + +func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + o := snack.ApplyOptions(opts...) + var toUpgrade []snack.Target + var unchanged []string + for _, t := range pkgs { + if o.DryRun { + toUpgrade = append(toUpgrade, t) + continue + } + ok, err := isInstalled(ctx, t.Name) + if err != nil { + return snack.InstallResult{}, err + } + if !ok { + unchanged = append(unchanged, t.Name) + } else { + toUpgrade = append(toUpgrade, t) + } + } + for _, t := range toUpgrade { + if _, err := run(ctx, []string{"upgrade", t.Name}); err != nil { + return snack.InstallResult{}, fmt.Errorf("brew upgrade %s: %w", t.Name, err) + } + } + var upgraded []snack.Package + for _, t := range toUpgrade { + v, _ := version(ctx, t.Name) + upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true}) + } + return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil +} diff --git a/brew/capabilities.go b/brew/capabilities.go new file mode 100644 index 0000000..8ea13db --- /dev/null +++ b/brew/capabilities.go @@ -0,0 +1,12 @@ +package brew + +import "github.com/gogrlx/snack" + +// Compile-time interface checks. +var ( + _ snack.Manager = (*Brew)(nil) + _ snack.VersionQuerier = (*Brew)(nil) + _ snack.Cleaner = (*Brew)(nil) + _ snack.FileOwner = (*Brew)(nil) + _ snack.NameNormalizer = (*Brew)(nil) +) diff --git a/brew/normalize.go b/brew/normalize.go new file mode 100644 index 0000000..be6cd59 --- /dev/null +++ b/brew/normalize.go @@ -0,0 +1,21 @@ +package brew + +import "strings" + +// normalizeName returns the canonical form of a package name. +// Homebrew formulae can have version suffixes like `python@3.12`. +// This strips the version suffix to get the base formula name. +func normalizeName(name string) string { + n, _ := parseVersionSuffix(name) + return n +} + +// parseVersionSuffix extracts the version suffix from a formula name. +// Homebrew uses @ to denote versioned formulae (e.g., "python@3.12"). +// Returns the name without version and the version string. +func parseVersionSuffix(name string) (string, string) { + if idx := strings.LastIndex(name, "@"); idx > 0 { + return name[:idx], name[idx+1:] + } + return name, "" +} diff --git a/cmd/snack/main.go b/cmd/snack/main.go index 1abafef..ec21892 100644 --- a/cmd/snack/main.go +++ b/cmd/snack/main.go @@ -47,6 +47,9 @@ behind a single, consistent interface.`, holdCmd(), unholdCmd(), cleanCmd(), + repoCmd(), + keyCmd(), + groupCmd(), detectCmd(), versionCmd(), ) @@ -412,3 +415,237 @@ func versionCmd() *cobra.Command { }, } } + +func repoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo", + Short: "Manage package repositories", + } + cmd.AddCommand(repoListCmd(), repoAddCmd(), repoRemoveCmd()) + return cmd +} + +func repoListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured repositories", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + m, err := getManager() + if err != nil { + return err + } + rm, ok := m.(snack.RepoManager) + if !ok { + return fmt.Errorf("%s does not support repository management", m.Name()) + } + repos, err := rm.ListRepos(cmd.Context()) + if err != nil { + return err + } + for _, r := range repos { + status := "disabled" + if r.Enabled { + status = "enabled" + } + fmt.Printf("%s %s [%s]\n", r.ID, r.URL, status) + } + return nil + }, + } +} + +func repoAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add a package repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + rm, ok := m.(snack.RepoManager) + if !ok { + return fmt.Errorf("%s does not support repository management", m.Name()) + } + repo := snack.Repository{ + URL: args[0], + Enabled: true, + } + return rm.AddRepo(cmd.Context(), repo) + }, + } +} + +func repoRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a package repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + rm, ok := m.(snack.RepoManager) + if !ok { + return fmt.Errorf("%s does not support repository management", m.Name()) + } + return rm.RemoveRepo(cmd.Context(), args[0]) + }, + } +} + +func keyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage GPG signing keys", + } + cmd.AddCommand(keyListCmd(), keyAddCmd(), keyRemoveCmd()) + return cmd +} + +func keyListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List trusted signing keys", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + m, err := getManager() + if err != nil { + return err + } + km, ok := m.(snack.KeyManager) + if !ok { + return fmt.Errorf("%s does not support key management", m.Name()) + } + keys, err := km.ListKeys(cmd.Context()) + if err != nil { + return err + } + for _, k := range keys { + fmt.Println(k) + } + return nil + }, + } +} + +func keyAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add a GPG signing key (URL, file path, or key ID)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + km, ok := m.(snack.KeyManager) + if !ok { + return fmt.Errorf("%s does not support key management", m.Name()) + } + return km.AddKey(cmd.Context(), args[0]) + }, + } +} + +func keyRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a GPG signing key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + km, ok := m.(snack.KeyManager) + if !ok { + return fmt.Errorf("%s does not support key management", m.Name()) + } + return km.RemoveKey(cmd.Context(), args[0]) + }, + } +} + +func groupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "group", + Short: "Manage package groups", + } + cmd.AddCommand(groupListCmd(), groupInfoCmd(), groupInstallCmd()) + return cmd +} + +func groupListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available package groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + m, err := getManager() + if err != nil { + return err + } + g, ok := m.(snack.Grouper) + if !ok { + return fmt.Errorf("%s does not support package groups", m.Name()) + } + groups, err := g.GroupList(cmd.Context()) + if err != nil { + return err + } + for _, grp := range groups { + fmt.Println(grp) + } + return nil + }, + } +} + +func groupInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show packages in a group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + g, ok := m.(snack.Grouper) + if !ok { + return fmt.Errorf("%s does not support package groups", m.Name()) + } + pkgs, err := g.GroupInfo(cmd.Context(), args[0]) + if err != nil { + return err + } + for _, p := range pkgs { + fmt.Println(p.Name) + } + return nil + }, + } +} + +func groupInstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "install ", + Short: "Install all packages in a group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := getManager() + if err != nil { + return err + } + g, ok := m.(snack.Grouper) + if !ok { + return fmt.Errorf("%s does not support package groups", m.Name()) + } + return g.GroupInstall(cmd.Context(), args[0], opts()...) + }, + } +} diff --git a/cmd/snack/main_test.go b/cmd/snack/main_test.go index 8739de6..f81a70a 100644 --- a/cmd/snack/main_test.go +++ b/cmd/snack/main_test.go @@ -122,14 +122,15 @@ func TestGetManager(t *testing.T) { t.Error("expected non-empty manager name") } - // Explicit override - flagMgr = "apt" - m, err = getManager() + // Explicit override - use the detected manager's name + // since not all managers are available on all platforms + flagMgr = m.Name() + m2, err := getManager() if err != nil { - t.Fatalf("getManager() with --manager=apt failed: %v", err) + t.Fatalf("getManager() with --manager=%s failed: %v", flagMgr, err) } - if m.Name() != "apt" { - t.Errorf("expected Name()=apt, got %q", m.Name()) + if m2.Name() != flagMgr { + t.Errorf("expected Name()=%s, got %q", flagMgr, m2.Name()) } // Unknown manager diff --git a/detect/detect_darwin.go b/detect/detect_darwin.go new file mode 100644 index 0000000..c8d45f0 --- /dev/null +++ b/detect/detect_darwin.go @@ -0,0 +1,20 @@ +//go:build darwin + +package detect + +import ( + "github.com/gogrlx/snack" + "github.com/gogrlx/snack/brew" +) + +// candidates returns manager factories in probe order for macOS. +func candidates() []managerFactory { + return []managerFactory{ + func() snack.Manager { return brew.New() }, + } +} + +// allManagers returns all known manager factories (for ByName). +func allManagers() []managerFactory { + return candidates() +} diff --git a/detect/detect_linux.go b/detect/detect_linux.go index 7b41027..fc088d9 100644 --- a/detect/detect_linux.go +++ b/detect/detect_linux.go @@ -7,6 +7,7 @@ import ( "github.com/gogrlx/snack/apk" "github.com/gogrlx/snack/apt" "github.com/gogrlx/snack/aur" + "github.com/gogrlx/snack/brew" "github.com/gogrlx/snack/dnf" "github.com/gogrlx/snack/flatpak" "github.com/gogrlx/snack/pacman" @@ -23,11 +24,12 @@ func candidates() []managerFactory { func() snack.Manager { return apk.New() }, func() snack.Manager { return flatpak.New() }, func() snack.Manager { return snap.New() }, + func() snack.Manager { return brew.New() }, + func() snack.Manager { return aur.New() }, } } // allManagers returns all known manager factories (for ByName). -// Includes supplemental managers like AUR that aren't primary candidates. func allManagers() []managerFactory { - return append(candidates(), func() snack.Manager { return aur.New() }) + return candidates() } diff --git a/detect/detect_other.go b/detect/detect_other.go index 98aa9e4..0ad262c 100644 --- a/detect/detect_other.go +++ b/detect/detect_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !freebsd && !openbsd +//go:build !linux && !freebsd && !openbsd && !darwin package detect diff --git a/detect/detect_test.go b/detect/detect_test.go index 7b548f1..16e701b 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -1,3 +1,5 @@ +//go:build linux + package detect import ( diff --git a/detect/detect_windows.go b/detect/detect_windows.go new file mode 100644 index 0000000..664b6d0 --- /dev/null +++ b/detect/detect_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package detect + +// candidates returns manager factories in probe order for Windows. +// Currently no Windows package managers are supported. +func candidates() []managerFactory { + return nil +} + +// allManagers returns all known manager factories (for ByName). +func allManagers() []managerFactory { + return nil +} diff --git a/flatpak/capabilities.go b/flatpak/capabilities.go index 3d74050..fc3c732 100644 --- a/flatpak/capabilities.go +++ b/flatpak/capabilities.go @@ -8,11 +8,32 @@ import ( // Compile-time interface checks. var ( + _ snack.VersionQuerier = (*Flatpak)(nil) _ snack.Cleaner = (*Flatpak)(nil) _ snack.RepoManager = (*Flatpak)(nil) - _ snack.VersionQuerier = (*Flatpak)(nil) + _ snack.NameNormalizer = (*Flatpak)(nil) ) +// LatestVersion returns the latest available version of a flatpak. +func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns flatpaks that have newer versions available. +func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings. +func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + // Autoremove removes unused runtimes and extensions. func (f *Flatpak) Autoremove(ctx context.Context, opts ...snack.Option) error { f.Lock() @@ -43,25 +64,3 @@ func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error { defer f.Unlock() return removeRepo(ctx, id) } - -// LatestVersion returns the latest available version of a flatpak from -// configured remotes. -func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) { - return latestVersion(ctx, pkg) -} - -// ListUpgrades returns flatpaks that have newer versions available. -func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) { - return listUpgrades(ctx) -} - -// UpgradeAvailable reports whether a newer version is available. -func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { - return upgradeAvailable(ctx, pkg) -} - -// VersionCmp compares two version strings using basic semver comparison. -// Flatpak has no native version comparison tool. -func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { - return versionCmp(ctx, ver1, ver2) -} diff --git a/flatpak/capabilities_linux.go b/flatpak/capabilities_linux.go deleted file mode 100644 index 14ef8b0..0000000 --- a/flatpak/capabilities_linux.go +++ /dev/null @@ -1,56 +0,0 @@ -//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 -} diff --git a/flatpak/capabilities_other.go b/flatpak/capabilities_other.go deleted file mode 100644 index 9dadcec..0000000 --- a/flatpak/capabilities_other.go +++ /dev/null @@ -1,25 +0,0 @@ -//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 -} diff --git a/flatpak/flatpak.go b/flatpak/flatpak.go index 3978f72..8bbda38 100644 --- a/flatpak/flatpak.go +++ b/flatpak/flatpak.go @@ -81,6 +81,16 @@ func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } +// NormalizeName returns the canonical form of a flatpak app ID. +func (f *Flatpak) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a flatpak reference if present. +func (f *Flatpak) ParseArch(name string) (string, string) { + return parseRef(name) +} + // Verify interface compliance at compile time. var _ snack.Manager = (*Flatpak)(nil) var _ snack.PackageUpgrader = (*Flatpak)(nil) diff --git a/flatpak/flatpak_linux.go b/flatpak/flatpak_linux.go index 6edb831..ec9c92c 100644 --- a/flatpak/flatpak_linux.go +++ b/flatpak/flatpak_linux.go @@ -220,11 +220,8 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt if len(toUpgrade) > 0 { for _, t := range toUpgrade { args := []string{"update", "-y", t.Name} - cmd := exec.CommandContext(ctx, "flatpak", args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w: %s", t.Name, err, stderr.String()) + if _, err := run(ctx, args); err != nil { + return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w", t.Name, err) } } } diff --git a/flatpak/flatpak_other.go b/flatpak/flatpak_other.go index ef10714..81a616e 100644 --- a/flatpak/flatpak_other.go +++ b/flatpak/flatpak_other.go @@ -62,6 +62,22 @@ 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 } diff --git a/flatpak/flatpak_test.go b/flatpak/flatpak_test.go index f03aeff..fb6abe8 100644 --- a/flatpak/flatpak_test.go +++ b/flatpak/flatpak_test.go @@ -397,8 +397,8 @@ func TestCapabilities(t *testing.T) { if caps.Groups { t.Error("expected Groups=false") } - if caps.NameNormalize { - t.Error("expected NameNormalize=false") + if !caps.NameNormalize { + t.Error("expected NameNormalize=true") } if !caps.PackageUpgrade { t.Error("expected PackageUpgrade=true") diff --git a/flatpak/normalize.go b/flatpak/normalize.go new file mode 100644 index 0000000..1871263 --- /dev/null +++ b/flatpak/normalize.go @@ -0,0 +1,29 @@ +package flatpak + +import "strings" + +// normalizeName returns the canonical form of a flatpak application ID. +// Flatpak references can include branch/arch suffixes like: +// - org.gnome.Calculator/x86_64/stable +// - org.gnome.Calculator//stable (default arch) +// +// This strips branch and arch to return just the app ID. +func normalizeName(name string) string { + n, _ := parseRef(name) + return n +} + +// parseRef extracts the architecture from a flatpak reference if present. +// Flatpak references can be in the form: +// - app-id +// - app-id/arch/branch +// - app-id//branch (default arch) +// +// Returns the app-id and architecture (or empty string). +func parseRef(name string) (string, string) { + parts := strings.SplitN(name, "/", 3) + if len(parts) >= 2 { + return parts[0], parts[1] + } + return name, "" +} diff --git a/pacman/buildargs_linux_test.go b/pacman/buildargs_linux_test.go new file mode 100644 index 0000000..4fb5bd4 --- /dev/null +++ b/pacman/buildargs_linux_test.go @@ -0,0 +1,65 @@ +//go:build linux + +package pacman + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestBuildArgs(t *testing.T) { + tests := []struct { + name string + base []string + opts snack.Options + wantCmd string + wantArgs []string + }{ + { + name: "basic", + base: []string{"-S", "vim"}, + opts: snack.Options{}, + wantCmd: "pacman", + wantArgs: []string{"-S", "vim"}, + }, + { + name: "with sudo", + base: []string{"-S", "vim"}, + opts: snack.Options{Sudo: true}, + wantCmd: "sudo", + wantArgs: []string{"pacman", "-S", "vim"}, + }, + { + name: "with root and noconfirm", + base: []string{"-S", "vim"}, + opts: snack.Options{Root: "/mnt", AssumeYes: true}, + wantCmd: "pacman", + wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"}, + }, + { + name: "dry run", + base: []string{"-S", "vim"}, + opts: snack.Options{DryRun: true}, + wantCmd: "pacman", + wantArgs: []string{"-S", "vim", "--print"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, args := buildArgs(tt.base, tt.opts) + if cmd != tt.wantCmd { + t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd) + } + if len(args) != len(tt.wantArgs) { + t.Fatalf("args = %v, want %v", args, tt.wantArgs) + } + for i := range args { + if args[i] != tt.wantArgs[i] { + t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i]) + } + } + }) + } +} diff --git a/pacman/capabilities.go b/pacman/capabilities.go index a75620b..14e0683 100644 --- a/pacman/capabilities.go +++ b/pacman/capabilities.go @@ -12,6 +12,7 @@ var ( _ snack.Cleaner = (*Pacman)(nil) _ snack.FileOwner = (*Pacman)(nil) _ snack.Grouper = (*Pacman)(nil) + _ snack.NameNormalizer = (*Pacman)(nil) _ snack.DryRunner = (*Pacman)(nil) ) diff --git a/pacman/helpers_test.go b/pacman/helpers_test.go index dd04331..5852887 100644 --- a/pacman/helpers_test.go +++ b/pacman/helpers_test.go @@ -1,3 +1,5 @@ +//go:build linux + package pacman import ( diff --git a/pacman/normalize.go b/pacman/normalize.go new file mode 100644 index 0000000..fcdf082 --- /dev/null +++ b/pacman/normalize.go @@ -0,0 +1,16 @@ +package pacman + +// normalizeName returns the canonical form of a package name. +// Pacman package names do not include architecture suffixes, so this +// is essentially a pass-through. The package name is returned as-is. +func normalizeName(name string) string { + return name +} + +// parseArch extracts the architecture from a package name if present. +// Pacman package names do not include architecture suffixes in the name itself +// (the arch is separate metadata), so this returns the name unchanged with an +// empty architecture string. +func parseArchNormalize(name string) (string, string) { + return name, "" +} diff --git a/pacman/pacman.go b/pacman/pacman.go index 236498b..24d45ad 100644 --- a/pacman/pacman.go +++ b/pacman/pacman.go @@ -83,6 +83,16 @@ func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } +// NormalizeName returns the canonical form of a package name. +func (p *Pacman) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (p *Pacman) ParseArch(name string) (string, string) { + return parseArchNormalize(name) +} + // Verify interface compliance at compile time. var _ snack.Manager = (*Pacman)(nil) var _ snack.PackageUpgrader = (*Pacman)(nil) diff --git a/pacman/pacman_test.go b/pacman/pacman_test.go index bf0051f..632d83a 100644 --- a/pacman/pacman_test.go +++ b/pacman/pacman_test.go @@ -72,62 +72,6 @@ Architecture : x86_64 } } -func TestBuildArgs(t *testing.T) { - tests := []struct { - name string - base []string - opts snack.Options - wantCmd string - wantArgs []string - }{ - { - name: "basic", - base: []string{"-S", "vim"}, - opts: snack.Options{}, - wantCmd: "pacman", - wantArgs: []string{"-S", "vim"}, - }, - { - name: "with sudo", - base: []string{"-S", "vim"}, - opts: snack.Options{Sudo: true}, - wantCmd: "sudo", - wantArgs: []string{"pacman", "-S", "vim"}, - }, - { - name: "with root and noconfirm", - base: []string{"-S", "vim"}, - opts: snack.Options{Root: "/mnt", AssumeYes: true}, - wantCmd: "pacman", - wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"}, - }, - { - name: "dry run", - base: []string{"-S", "vim"}, - opts: snack.Options{DryRun: true}, - wantCmd: "pacman", - wantArgs: []string{"-S", "vim", "--print"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd, args := buildArgs(tt.base, tt.opts) - if cmd != tt.wantCmd { - t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd) - } - if len(args) != len(tt.wantArgs) { - t.Fatalf("args = %v, want %v", args, tt.wantArgs) - } - for i := range args { - if args[i] != tt.wantArgs[i] { - t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i]) - } - } - }) - } -} - func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Pacman)(nil) var _ snack.VersionQuerier = (*Pacman)(nil) @@ -151,8 +95,8 @@ func TestInterfaceNonCompliance(t *testing.T) { if _, ok := m.(snack.KeyManager); ok { t.Error("Pacman should not implement KeyManager") } - if _, ok := m.(snack.NameNormalizer); ok { - t.Error("Pacman should not implement NameNormalizer") + if _, ok := m.(snack.NameNormalizer); !ok { + t.Error("Pacman should implement NameNormalizer") } } @@ -172,7 +116,7 @@ func TestCapabilities(t *testing.T) { {"Hold", caps.Hold, false}, {"RepoManagement", caps.RepoManagement, false}, {"KeyManagement", caps.KeyManagement, false}, - {"NameNormalize", caps.NameNormalize, false}, + {"NameNormalize", caps.NameNormalize, true}, {"PackageUpgrade", caps.PackageUpgrade, true}, } diff --git a/pkg/capabilities.go b/pkg/capabilities.go index 050f7a7..2a70e14 100644 --- a/pkg/capabilities.go +++ b/pkg/capabilities.go @@ -11,6 +11,7 @@ var ( _ snack.VersionQuerier = (*Pkg)(nil) _ snack.Cleaner = (*Pkg)(nil) _ snack.FileOwner = (*Pkg)(nil) + _ snack.NameNormalizer = (*Pkg)(nil) ) // LatestVersion returns the latest available version from configured repositories. diff --git a/pkg/normalize.go b/pkg/normalize.go new file mode 100644 index 0000000..c6a80f9 --- /dev/null +++ b/pkg/normalize.go @@ -0,0 +1,17 @@ +package pkg + +// normalizeName returns the canonical form of a package name. +// FreeBSD pkg package names use "name-version" format. This function +// strips the version portion if present, returning just the name. +func normalizeName(name string) string { + n, _ := splitNameVersion(name) + return n +} + +// parseArchNormalize extracts the architecture from a package name if present. +// FreeBSD pkg package names do not embed architecture in the name itself +// (the arch is separate metadata), so this returns the name unchanged with +// an empty architecture string. +func parseArchNormalize(name string) (string, string) { + return name, "" +} diff --git a/pkg/pkg.go b/pkg/pkg.go index 0455124..1ef346a 100644 --- a/pkg/pkg.go +++ b/pkg/pkg.go @@ -83,6 +83,16 @@ func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } +// NormalizeName returns the canonical form of a package name. +func (p *Pkg) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (p *Pkg) ParseArch(name string) (string, string) { + return parseArchNormalize(name) +} + // Verify interface compliance at compile time. var _ snack.Manager = (*Pkg)(nil) var _ snack.PackageUpgrader = (*Pkg)(nil) diff --git a/pkg/pkg_test.go b/pkg/pkg_test.go index 3dc14ec..edb2b03 100644 --- a/pkg/pkg_test.go +++ b/pkg/pkg_test.go @@ -659,8 +659,8 @@ func TestCapabilities(t *testing.T) { if caps.Groups { t.Error("expected Groups=false") } - if caps.NameNormalize { - t.Error("expected NameNormalize=false") + if !caps.NameNormalize { + t.Error("expected NameNormalize=true") } if caps.DryRun { t.Error("expected DryRun=false") diff --git a/ports/capabilities.go b/ports/capabilities.go index 4f3f628..e2bb76a 100644 --- a/ports/capabilities.go +++ b/ports/capabilities.go @@ -11,6 +11,7 @@ var ( _ snack.VersionQuerier = (*Ports)(nil) _ snack.Cleaner = (*Ports)(nil) _ snack.FileOwner = (*Ports)(nil) + _ snack.NameNormalizer = (*Ports)(nil) ) // LatestVersion returns the latest available version from configured repositories. @@ -29,13 +30,11 @@ func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) } // VersionCmp compares two version strings. -// OpenBSD has no native version comparison tool, so this uses a simple -// lexicographic comparison of the version strings. func (p *Ports) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { return versionCmp(ctx, ver1, ver2) } -// Autoremove removes packages no longer required as dependencies. +// Autoremove removes packages that are no longer needed. func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error { p.Lock() defer p.Unlock() diff --git a/ports/capabilities_other.go b/ports/capabilities_other.go deleted file mode 100644 index 5441940..0000000 --- a/ports/capabilities_other.go +++ /dev/null @@ -1,45 +0,0 @@ -//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 -} diff --git a/ports/normalize.go b/ports/normalize.go new file mode 100644 index 0000000..328df6c --- /dev/null +++ b/ports/normalize.go @@ -0,0 +1,16 @@ +package ports + +// normalizeName returns the canonical form of a package name. +// OpenBSD packages use "name-version" format. This strips the version +// portion if present, returning just the name. +func normalizeName(name string) string { + n, _ := splitNameVersion(name) + return n +} + +// parseArchNormalize extracts the architecture from a package name if present. +// OpenBSD package names do not embed architecture in the name itself +// (the arch is separate), so this returns the name unchanged. +func parseArchNormalize(name string) (string, string) { + return name, "" +} diff --git a/ports/ports.go b/ports/ports.go index f67a94c..1245c55 100644 --- a/ports/ports.go +++ b/ports/ports.go @@ -81,6 +81,16 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } +// NormalizeName returns the canonical form of a package name. +func (p *Ports) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (p *Ports) ParseArch(name string) (string, string) { + return parseArchNormalize(name) +} + // Verify interface compliance at compile time. var _ snack.Manager = (*Ports)(nil) var _ snack.PackageUpgrader = (*Ports)(nil) diff --git a/ports/ports_openbsd.go b/ports/ports_openbsd.go index f7eae3a..faa82fd 100644 --- a/ports/ports_openbsd.go +++ b/ports/ports_openbsd.go @@ -183,3 +183,55 @@ func version(ctx context.Context, pkg string) (string, error) { } return p.Version, nil } + +func autoremove(ctx context.Context, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + // pkg_delete -a removes all packages not required by other packages + _, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o) + return err +} + +func clean(_ context.Context) error { + // OpenBSD doesn't cache packages by default, no-op + return nil +} + +func fileList(ctx context.Context, pkg string) ([]string, error) { + out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("ports fileList: %w", err) + } + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Information for") { + continue + } + if strings.HasPrefix(line, "Files:") { + continue + } + if strings.HasPrefix(line, "/") { + files = append(files, line) + } + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound) + } + return "", fmt.Errorf("ports owner: %w", err) + } + out = strings.TrimSpace(out) + if out == "" { + return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound) + } + // Output is the package name + return strings.Split(out, "\n")[0], nil +} diff --git a/ports/ports_other.go b/ports/ports_other.go index 5242806..8693473 100644 --- a/ports/ports_other.go +++ b/ports/ports_other.go @@ -49,3 +49,39 @@ 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 +} diff --git a/ports/ports_test.go b/ports/ports_test.go index a7eb3c9..b097451 100644 --- a/ports/ports_test.go +++ b/ports/ports_test.go @@ -796,8 +796,8 @@ func TestCapabilities(t *testing.T) { if caps.Groups { t.Error("expected Groups=false") } - if caps.NameNormalize { - t.Error("expected NameNormalize=false") + if !caps.NameNormalize { + t.Error("expected NameNormalize=true") } if caps.DryRun { t.Error("expected DryRun=false") diff --git a/snap/capabilities.go b/snap/capabilities.go index 3214d2c..9f8a9a8 100644 --- a/snap/capabilities.go +++ b/snap/capabilities.go @@ -10,6 +10,7 @@ import ( var ( _ snack.VersionQuerier = (*Snap)(nil) _ snack.Cleaner = (*Snap)(nil) + _ snack.NameNormalizer = (*Snap)(nil) ) // LatestVersion returns the latest stable version of a snap. @@ -32,13 +33,12 @@ func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { return versionCmp(ctx, ver1, ver2) } -// Autoremove is a no-op for snap. Snaps are self-contained and do not -// have orphan dependencies. -func (s *Snap) Autoremove(ctx context.Context, opts ...snack.Option) error { - return autoremove(ctx, opts...) +// Autoremove is a no-op for snap (snap doesn't have orphan packages). +func (s *Snap) Autoremove(_ context.Context, _ ...snack.Option) error { + return nil } -// Clean removes old disabled snap revisions to free disk space. +// Clean removes old snap revisions to free up space. func (s *Snap) Clean(ctx context.Context) error { s.Lock() defer s.Unlock() diff --git a/snap/normalize.go b/snap/normalize.go new file mode 100644 index 0000000..e7a9111 --- /dev/null +++ b/snap/normalize.go @@ -0,0 +1,15 @@ +package snap + +// normalizeName returns the canonical form of a snap name. +// Snap 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 snap name if present. +// Snap 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, "" +} diff --git a/snap/snap.go b/snap/snap.go index 9e0bad0..bca3b78 100644 --- a/snap/snap.go +++ b/snap/snap.go @@ -81,6 +81,16 @@ func (s *Snap) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } +// NormalizeName returns the canonical form of a snap name. +func (s *Snap) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a snap name if present. +func (s *Snap) ParseArch(name string) (string, string) { + return parseArch(name) +} + // Verify interface compliance at compile time. var _ snack.Manager = (*Snap)(nil) var _ snack.PackageUpgrader = (*Snap)(nil) diff --git a/snap/snap_linux.go b/snap/snap_linux.go index 8de5e97..d933595 100644 --- a/snap/snap_linux.go +++ b/snap/snap_linux.go @@ -232,43 +232,21 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) { return semverCmp(ver1, ver2), nil } -// autoremove is a no-op for snap. Snaps are self-contained and do not -// have orphan dependencies. -func autoremove(_ context.Context, _ ...snack.Option) error { - return nil -} - -// clean removes old disabled snap revisions to free disk space. -// It runs `snap list --all` to find disabled revisions, then removes -// each one with `snap remove --revision= `. func clean(ctx context.Context) error { + // Remove disabled snap revisions to free up space out, err := run(ctx, []string{"list", "--all"}) if err != nil { - return fmt.Errorf("snap clean: %w", err) + return err } - // Parse output for disabled revisions - // Header: Name Version Rev Tracking Publisher Notes - // Disabled snaps have "disabled" in the Notes column - lines := strings.Split(out, "\n") - for i, line := range lines { - if i == 0 { // skip header - continue - } - line = strings.TrimSpace(line) - if line == "" { - continue - } + for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, "disabled") { continue } fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - name := fields[0] - rev := fields[2] - if _, err := run(ctx, []string{"remove", "--revision=" + rev, name}); err != nil { - return fmt.Errorf("snap clean %s rev %s: %w", name, rev, err) + if len(fields) >= 3 { + name := fields[0] + rev := fields[2] + _, _ = run(ctx, []string{"remove", name, "--revision=" + rev}) } } return nil @@ -293,14 +271,9 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt toUpgrade = append(toUpgrade, t) } } - if len(toUpgrade) > 0 { - for _, t := range toUpgrade { - cmd := exec.CommandContext(ctx, "snap", "refresh", t.Name) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w: %s", t.Name, err, stderr.String()) - } + for _, t := range toUpgrade { + if _, err := run(ctx, []string{"refresh", t.Name}); err != nil { + return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w", t.Name, err) } } var upgraded []snack.Package diff --git a/snap/snap_other.go b/snap/snap_other.go index 3831667..cc8ab12 100644 --- a/snap/snap_other.go +++ b/snap/snap_other.go @@ -66,10 +66,6 @@ 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 } diff --git a/snap/snap_test.go b/snap/snap_test.go index c0566d9..20d11e9 100644 --- a/snap/snap_test.go +++ b/snap/snap_test.go @@ -441,8 +441,8 @@ func TestCapabilities(t *testing.T) { if caps.Groups { t.Error("expected Groups=false") } - if caps.NameNormalize { - t.Error("expected NameNormalize=false") + if !caps.NameNormalize { + t.Error("expected NameNormalize=true") } if !caps.PackageUpgrade { t.Error("expected PackageUpgrade=true")