mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-07 15:42:29 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b0454851 | |||
| 2422655c1d | |||
| 4a9a2b1980 | |||
| 1da329dfb5 | |||
| f53534ce6f | |||
| d30c9fec0e | |||
| 98bfc56960 | |||
| c913d96de3 | |||
| ffbe0e12ba | |||
| 6237a5f23a | |||
| ac15ab5a49 | |||
| 9e9fb1a822 | |||
| aed2ee8b86 | |||
| 84f9cbc9cf | |||
| 151c657398 | |||
| 934c6610c5 | |||
| 724ecc866e |
36
.github/workflows/integration.yml
vendored
36
.github/workflows/integration.yml
vendored
@@ -179,6 +179,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak
|
||||
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install -y flathub com.github.tchx84.Flatseal
|
||||
- name: Integration tests
|
||||
run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-flatpak.out ./flatpak/
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -187,10 +188,41 @@ jobs:
|
||||
name: coverage-flatpak
|
||||
path: coverage-flatpak.out
|
||||
|
||||
cross-compile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, darwin, freebsd, openbsd]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build for ${{ matrix.goos }}
|
||||
run: GOOS=${{ matrix.goos }} go build ./...
|
||||
|
||||
windows:
|
||||
name: Windows (winget)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
run: go test -race -coverprofile=coverage-windows.out ./winget/ ./detect/
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.out
|
||||
|
||||
codecov:
|
||||
name: Upload Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak]
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak, windows, cross-compile]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -202,7 +234,7 @@ jobs:
|
||||
run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found"
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out,coverage-windows.out
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
coverage.out
|
||||
coverage.html
|
||||
.DS_Store
|
||||
.crush/
|
||||
AGENTS.md
|
||||
|
||||
149
README.md
149
README.md
@@ -11,22 +11,38 @@ 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 | ✅ |
|
||||
| `winget` | Windows Package Manager | Windows | ✅ |
|
||||
| `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 | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| winget | ✅ | - | - | - | ✅ | - | - | ✅ | - | ✅ |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -52,12 +68,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 +80,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 +93,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 +101,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 +121,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 <name>`, `--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 +133,28 @@ Global flags: `--manager <name>`, `--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)
|
||||
7. winget (Windows)
|
||||
|
||||
## 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).
|
||||
|
||||
19
apk/apk.go
19
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)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Apk(t *testing.T) {
|
||||
assert.False(t, caps.RepoManagement, "apk should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "apk should not support KeyManagement")
|
||||
assert.False(t, caps.Groups, "apk should not support Groups")
|
||||
assert.False(t, caps.NameNormalize, "apk should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "apk should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
38
apk/normalize.go
Normal file
38
apk/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
39
apk/parse.go
39
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
|
||||
}
|
||||
|
||||
97
aur/aur.go
97
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)
|
||||
}
|
||||
|
||||
617
aur/aur_linux.go
617
aur/aur_linux.go
@@ -5,59 +5,67 @@ 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 +77,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 +89,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 +195,188 @@ 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...)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
229
aur/aur_test.go
229
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
func (a *AUR) Clean(_ context.Context) error {
|
||||
return a.cleanBuildDir()
|
||||
// Clean is a no-op for AUR (builds use temp directories).
|
||||
func (a *AUR) Clean(ctx context.Context) error {
|
||||
return clean(ctx)
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
|
||||
15
aur/normalize.go
Normal file
15
aur/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
21
aur/rpc.go
21
aur/rpc.go
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
@@ -117,23 +116,3 @@ func rpcInfo(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// rpcInfoMulti returns info about multiple AUR packages in a single request.
|
||||
func rpcInfoMulti(ctx context.Context, pkgs []string) (map[string]rpcResult, error) {
|
||||
if len(pkgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
params := make([]string, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
params[i] = "arg[]=" + url.QueryEscape(p)
|
||||
}
|
||||
endpoint := rpcBaseURL + "/info?" + strings.Join(params, "&")
|
||||
resp, err := rpcGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]rpcResult, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
result[r.Name] = r
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
156
brew/brew.go
Normal file
156
brew/brew.go
Normal file
@@ -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...)
|
||||
}
|
||||
87
brew/brew_other.go
Normal file
87
brew/brew_other.go
Normal file
@@ -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
|
||||
}
|
||||
223
brew/brew_test.go
Normal file
223
brew/brew_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
473
brew/brew_unix.go
Normal file
473
brew/brew_unix.go
Normal file
@@ -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
|
||||
}
|
||||
12
brew/capabilities.go
Normal file
12
brew/capabilities.go
Normal file
@@ -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)
|
||||
)
|
||||
21
brew/normalize.go
Normal file
21
brew/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
@@ -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 <url>",
|
||||
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 <id>",
|
||||
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 <key>",
|
||||
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 <key-id>",
|
||||
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 <group>",
|
||||
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 <group>",
|
||||
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()...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
20
detect/detect_darwin.go
Normal file
20
detect/detect_darwin.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !freebsd && !openbsd
|
||||
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
|
||||
|
||||
package detect
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
|
||||
20
detect/detect_windows.go
Normal file
20
detect/detect_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
)
|
||||
|
||||
// candidates returns manager factories in probe order for Windows.
|
||||
func candidates() []managerFactory {
|
||||
return []managerFactory{
|
||||
func() snack.Manager { return winget.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
func allManagers() []managerFactory {
|
||||
return candidates()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Flatpak(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.Clean, "flatpak should support Clean")
|
||||
assert.True(t, caps.RepoManagement, "flatpak should support RepoManagement")
|
||||
assert.False(t, caps.VersionQuery)
|
||||
assert.True(t, caps.VersionQuery, "flatpak should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "flatpak should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -198,6 +198,49 @@ func removeRepo(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"remote-info", "flathub", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("flatpak latestVersion: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"remote-ls", "--updates", "--columns=name,application,version,origin"})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No updates") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("flatpak listUpgrades: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range upgrades {
|
||||
if u.Name == pkg || u.Description == pkg {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
@@ -220,11 +263,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
29
flatpak/normalize.go
Normal file
29
flatpak/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
65
pacman/buildargs_linux_test.go
Normal file
65
pacman/buildargs_linux_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
|
||||
16
pacman/normalize.go
Normal file
16
pacman/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Pacman(t *testing.T) {
|
||||
assert.False(t, caps.Hold, "pacman should not support Hold")
|
||||
assert.False(t, caps.RepoManagement, "pacman should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "pacman should not support KeyManagement")
|
||||
assert.False(t, caps.NameNormalize, "pacman should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "pacman should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
pkg/normalize.go
Normal file
17
pkg/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
10
pkg/pkg.go
10
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -78,40 +78,6 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
// OpenBSD does not maintain a package cache like FreeBSD/apt.
|
||||
// Downloaded packages are removed after installation by default.
|
||||
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)
|
||||
}
|
||||
return parseFileListOutput(out), 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)
|
||||
}
|
||||
return parseOwnerOutput(out), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
|
||||
@@ -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
|
||||
}
|
||||
16
ports/normalize.go
Normal file
16
ports/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
15
snap/normalize.go
Normal file
15
snap/normalize.go
Normal file
@@ -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, ""
|
||||
}
|
||||
10
snap/snap.go
10
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)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Snap(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "snap should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.True(t, caps.Clean, "snap should support Clean")
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.RepoManagement)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "snap should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -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=<rev> <name>`.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
49
winget/capabilities.go
Normal file
49
winget/capabilities.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Winget)(nil)
|
||||
_ snack.RepoManager = (*Winget)(nil)
|
||||
_ snack.NameNormalizer = (*Winget)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version of a package.
|
||||
func (w *Winget) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (w *Winget) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (w *Winget) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (w *Winget) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// ListRepos returns configured winget sources.
|
||||
func (w *Winget) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
return sourceList(ctx)
|
||||
}
|
||||
|
||||
// AddRepo adds a new winget source.
|
||||
func (w *Winget) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||
return sourceAdd(ctx, repo)
|
||||
}
|
||||
|
||||
// RemoveRepo removes a configured winget source.
|
||||
func (w *Winget) RemoveRepo(ctx context.Context, id string) error {
|
||||
return sourceRemove(ctx, id)
|
||||
}
|
||||
19
winget/normalize.go
Normal file
19
winget/normalize.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package winget
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a winget package ID.
|
||||
// Winget IDs use dot-separated Publisher.Package format (e.g.
|
||||
// "Microsoft.VisualStudioCode"). This trims whitespace but otherwise
|
||||
// preserves the ID as-is since winget IDs are case-insensitive but
|
||||
// conventionally PascalCase.
|
||||
func normalizeName(name string) string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a winget package name if present.
|
||||
// Winget IDs do not include architecture suffixes, so this returns the
|
||||
// name unchanged with an empty string.
|
||||
func parseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
333
winget/parse.go
Normal file
333
winget/parse.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseTable parses winget tabular output (list, search, upgrade).
|
||||
//
|
||||
// Winget uses fixed-width columns whose positions are determined by a
|
||||
// header row with dashes (e.g. "---- ------ -------").
|
||||
// The header names vary by locale, so we detect columns positionally.
|
||||
//
|
||||
// Typical `winget list` output:
|
||||
//
|
||||
// Name Id Version Available Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.8.0 17.9.0 winget
|
||||
//
|
||||
// Typical `winget search` output:
|
||||
//
|
||||
// Name Id Version Match Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.9.0 winget
|
||||
//
|
||||
// When installed is true, we mark all parsed packages as Installed.
|
||||
func parseTable(output string, installed bool) []snack.Package {
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
// Find the separator line (all dashes/spaces) to determine column positions.
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if isSeparatorLine(trimmed) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the header line (just above separator) to determine column starts.
|
||||
header := lines[sepIdx-1]
|
||||
cols := detectColumns(header)
|
||||
if len(cols) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pkgs []snack.Package
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
// Skip footer lines like "X upgrades available."
|
||||
if isFooterLine(line) {
|
||||
continue
|
||||
}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Installed: installed,
|
||||
}
|
||||
// Column order: Name, Id, Version, [Available], [Source]
|
||||
// For search: Name, Id, Version, [Match], [Source]
|
||||
if len(fields) >= 3 {
|
||||
pkg.Version = fields[2]
|
||||
}
|
||||
// Use the ID as the package name for consistency (winget uses
|
||||
// Publisher.Package IDs as the canonical identifier).
|
||||
if len(fields) >= 2 && fields[1] != "" {
|
||||
pkg.Description = fields[0] // keep display name as description
|
||||
pkg.Name = fields[1]
|
||||
}
|
||||
// If there's a Source column (typically the last), use it.
|
||||
if len(fields) >= 5 && fields[len(fields)-1] != "" {
|
||||
pkg.Repository = fields[len(fields)-1]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// isSeparatorLine returns true if the line is a column separator
|
||||
// (composed entirely of dashes and spaces, with at least some dashes).
|
||||
func isSeparatorLine(line string) bool {
|
||||
hasDash := false
|
||||
for _, c := range line {
|
||||
switch c {
|
||||
case '-':
|
||||
hasDash = true
|
||||
case ' ', '\t':
|
||||
// allowed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDash
|
||||
}
|
||||
|
||||
// detectColumns returns the starting index of each column based on
|
||||
// the header line. Columns are separated by 2+ spaces.
|
||||
func detectColumns(header string) []int {
|
||||
var cols []int
|
||||
inWord := false
|
||||
for i, c := range header {
|
||||
if c != ' ' && c != '\t' {
|
||||
if !inWord {
|
||||
cols = append(cols, i)
|
||||
inWord = true
|
||||
}
|
||||
} else {
|
||||
// Need at least 2 spaces to end a column
|
||||
if inWord && i+1 < len(header) && (header[i+1] == ' ' || header[i+1] == '\t') {
|
||||
inWord = false
|
||||
} else if inWord && i+1 >= len(header) {
|
||||
inWord = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// extractFields splits a data line according to detected column positions.
|
||||
func extractFields(line string, cols []int) []string {
|
||||
fields := make([]string, len(cols))
|
||||
for i, start := range cols {
|
||||
if start >= len(line) {
|
||||
break
|
||||
}
|
||||
end := len(line)
|
||||
if i+1 < len(cols) {
|
||||
end = cols[i+1]
|
||||
if end > len(line) {
|
||||
end = len(line)
|
||||
}
|
||||
}
|
||||
fields[i] = strings.TrimSpace(line[start:end])
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// isFooterLine returns true for winget output footer lines.
|
||||
func isFooterLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "upgrades available") ||
|
||||
strings.Contains(lower, "package(s)") ||
|
||||
strings.Contains(lower, "installed package") ||
|
||||
strings.HasPrefix(lower, "the following") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseShow parses `winget show` key-value output into a Package.
|
||||
//
|
||||
// Typical output:
|
||||
//
|
||||
// Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
// Version: 1.85.0
|
||||
// Publisher: Microsoft Corporation
|
||||
// Description: Code editing. Redefined.
|
||||
// ...
|
||||
func parseShow(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Parse "Found <Name> [<Id>]" header line.
|
||||
if strings.HasPrefix(line, "Found ") {
|
||||
if idx := strings.LastIndex(line, "["); idx > 0 {
|
||||
endIdx := strings.LastIndex(line, "]")
|
||||
if endIdx > idx {
|
||||
pkg.Name = strings.TrimSpace(line[idx+1 : endIdx])
|
||||
pkg.Description = strings.TrimSpace(line[6:idx])
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
switch strings.ToLower(key) {
|
||||
case "version":
|
||||
pkg.Version = val
|
||||
case "description":
|
||||
if pkg.Description == "" {
|
||||
pkg.Description = val
|
||||
}
|
||||
case "publisher":
|
||||
// informational only
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseSourceList parses `winget source list` output into Repositories.
|
||||
//
|
||||
// Output format:
|
||||
//
|
||||
// Name Argument
|
||||
// ----------------------------------------------
|
||||
// winget https://cdn.winget.microsoft.com/cache
|
||||
// msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
func parseSourceList(output string) []snack.Repository {
|
||||
lines := strings.Split(output, "\n")
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
if isSeparatorLine(strings.TrimSpace(line)) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
var repos []snack.Repository
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, snack.Repository{
|
||||
ID: fields[0],
|
||||
Name: fields[0],
|
||||
URL: fields[1],
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
return repos
|
||||
}
|
||||
|
||||
// stripVT removes ANSI/VT100 escape sequences from a string.
|
||||
func stripVT(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||
// Skip CSI sequence: ESC [ ... final byte
|
||||
j := i + 2
|
||||
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++ // skip final byte
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isProgressLine returns true for lines that are only progress indicators.
|
||||
func isProgressLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
// Lines containing block characters and/or percentage
|
||||
hasBlock := strings.ContainsAny(trimmed, "█▓░")
|
||||
hasPercent := strings.Contains(trimmed, "%")
|
||||
if hasBlock || (hasPercent && len(trimmed) < 40) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// semverCmp does a basic semver-ish comparison.
|
||||
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
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) {
|
||||
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
|
||||
}
|
||||
if i < len(partsB) {
|
||||
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
|
||||
}
|
||||
if numA < numB {
|
||||
return -1
|
||||
}
|
||||
if numA > numB {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stripNonNumeric keeps only leading digits from a string.
|
||||
func stripNonNumeric(s string) string {
|
||||
for i, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
103
winget/winget.go
Normal file
103
winget/winget.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package winget provides Go bindings for the Windows Package Manager (winget).
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Winget wraps the winget CLI.
|
||||
type Winget struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Winget manager.
|
||||
func New() *Winget {
|
||||
return &Winget{}
|
||||
}
|
||||
|
||||
// Name returns "winget".
|
||||
func (w *Winget) Name() string { return "winget" }
|
||||
|
||||
// Available reports whether winget is present on the system.
|
||||
func (w *Winget) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (w *Winget) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (w *Winget) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including configuration data.
|
||||
func (w *Winget) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (w *Winget) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the winget source index.
|
||||
func (w *Winget) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (w *Winget) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the winget repository for packages matching the query.
|
||||
func (w *Winget) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (w *Winget) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (w *Winget) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (w *Winget) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a winget package ID.
|
||||
func (w *Winget) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (w *Winget) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
|
||||
// UpgradePackages upgrades specific installed packages.
|
||||
func (w *Winget) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
140
winget/winget_integration_test.go
Normal file
140
winget/winget_integration_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build integration
|
||||
|
||||
package winget_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntegration_Winget(t *testing.T) {
|
||||
var mgr snack.Manager = winget.New()
|
||||
if !mgr.Available() {
|
||||
t.Skip("winget not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(t, "winget", mgr.Name())
|
||||
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "winget should support VersionQuery")
|
||||
assert.True(t, caps.RepoManagement, "winget should support RepoManagement")
|
||||
assert.True(t, caps.NameNormalize, "winget should support NameNormalize")
|
||||
assert.True(t, caps.PackageUpgrade, "winget should support PackageUpgrade")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.DryRun)
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Search_NoResults", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Info", func(t *testing.T) {
|
||||
pkg, err := mgr.Info(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pkg)
|
||||
assert.Equal(t, "Microsoft.PowerToys", pkg.Name)
|
||||
assert.NotEmpty(t, pkg.Version)
|
||||
})
|
||||
|
||||
t.Run("Info_NotFound", func(t *testing.T) {
|
||||
_, err := mgr.Info(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
pkgs, err := mgr.List(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("installed packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
// --- VersionQuerier ---
|
||||
t.Run("VersionQuerier", func(t *testing.T) {
|
||||
vq, ok := mgr.(snack.VersionQuerier)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("LatestVersion", func(t *testing.T) {
|
||||
ver, err := vq.LatestVersion(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ver)
|
||||
t.Logf("PowerToys latest: %s", ver)
|
||||
})
|
||||
|
||||
t.Run("LatestVersion_NotFound", func(t *testing.T) {
|
||||
_, err := vq.LatestVersion(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("ListUpgrades", func(t *testing.T) {
|
||||
pkgs, err := vq.ListUpgrades(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("upgradable packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
t.Run("VersionCmp", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
v1, v2 string
|
||||
want int
|
||||
}{
|
||||
{"1.0", "2.0", -1},
|
||||
{"2.0", "1.0", 1},
|
||||
{"1.0", "1.0", 0},
|
||||
{"1.0.1", "1.0.0", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2)
|
||||
require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- RepoManager ---
|
||||
t.Run("RepoManager", func(t *testing.T) {
|
||||
rm, ok := mgr.(snack.RepoManager)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("ListRepos", func(t *testing.T) {
|
||||
repos, err := rm.ListRepos(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, repos, "should have at least winget and msstore sources")
|
||||
t.Logf("sources: %d", len(repos))
|
||||
for _, r := range repos {
|
||||
t.Logf(" %s -> %s", r.Name, r.URL)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- NameNormalizer ---
|
||||
t.Run("NameNormalizer", func(t *testing.T) {
|
||||
nn, ok := mgr.(snack.NameNormalizer)
|
||||
require.True(t, ok)
|
||||
|
||||
name := nn.NormalizeName(" Microsoft.VisualStudioCode ")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", name)
|
||||
|
||||
n, arch := nn.ParseArch("Microsoft.VisualStudioCode")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", n)
|
||||
assert.Empty(t, arch)
|
||||
})
|
||||
}
|
||||
83
winget/winget_other.go
Normal file
83
winget/winget_other.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build !windows
|
||||
|
||||
package winget
|
||||
|
||||
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 upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceList(_ context.Context) ([]snack.Repository, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceAdd(_ context.Context, _ snack.Repository) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceRemove(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
449
winget/winget_test.go
Normal file
449
winget/winget_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseTableList(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Git Git.Git 2.43.0 winget
|
||||
Google Chrome Google.Chrome 120.0.6099.130 winget
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected ID as name, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "Visual Studio Code" {
|
||||
t.Errorf("expected display name as description, got %q", pkgs[0].Description)
|
||||
}
|
||||
if pkgs[0].Version != "1.85.0" {
|
||||
t.Errorf("expected version '1.85.0', got %q", pkgs[0].Version)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
if pkgs[0].Repository != "winget" {
|
||||
t.Errorf("expected repository 'winget', got %q", pkgs[0].Repository)
|
||||
}
|
||||
if pkgs[1].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableSearch(t *testing.T) {
|
||||
input := `Name Id Version Match Source
|
||||
-------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.86.0 Moniker: vscode winget
|
||||
VSCodium VSCodium.VSCodium 1.85.2 winget
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Installed {
|
||||
t.Error("expected Installed=false for search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableEmpty(t *testing.T) {
|
||||
input := `Name Id Version Source
|
||||
------------------------------
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableNoSeparator(t *testing.T) {
|
||||
pkgs := parseTable("No installed package found matching input criteria.", true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableWithFooter(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
--------------------------------------------------------------
|
||||
Git Git.Git 2.43.0 2.44.0 winget
|
||||
3 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableUpgrade(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Node.js OpenJS.NodeJS 20.10.0 21.5.0 winget
|
||||
2 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[1].Name != "OpenJS.NodeJS" {
|
||||
t.Errorf("expected 'OpenJS.NodeJS', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShow(t *testing.T) {
|
||||
input := `Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
Version: 1.86.0
|
||||
Publisher: Microsoft Corporation
|
||||
Publisher URL: https://code.visualstudio.com
|
||||
Author: Microsoft Corporation
|
||||
Moniker: vscode
|
||||
Description: Code editing. Redefined.
|
||||
License: MIT
|
||||
Installer Type: inno
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "1.86.0" {
|
||||
t.Errorf("expected version '1.86.0', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Description != "Visual Studio Code" {
|
||||
t.Errorf("expected 'Visual Studio Code', got %q", pkg.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNotFound(t *testing.T) {
|
||||
pkg := parseShow("No package found matching input criteria.")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for not-found output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowEmpty(t *testing.T) {
|
||||
pkg := parseShow("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNoID(t *testing.T) {
|
||||
input := `Version: 1.0.0
|
||||
Publisher: Someone
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Found header with ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceList(t *testing.T) {
|
||||
input := `Name Argument
|
||||
--------------------------------------------------------------
|
||||
winget https://cdn.winget.microsoft.com/cache
|
||||
msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
`
|
||||
repos := parseSourceList(input)
|
||||
if len(repos) != 2 {
|
||||
t.Fatalf("expected 2 repos, got %d", len(repos))
|
||||
}
|
||||
if repos[0].Name != "winget" {
|
||||
t.Errorf("expected 'winget', got %q", repos[0].Name)
|
||||
}
|
||||
if repos[0].URL != "https://cdn.winget.microsoft.com/cache" {
|
||||
t.Errorf("unexpected URL: %q", repos[0].URL)
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
if repos[1].Name != "msstore" {
|
||||
t.Errorf("expected 'msstore', got %q", repos[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceListEmpty(t *testing.T) {
|
||||
repos := parseSourceList("")
|
||||
if len(repos) != 0 {
|
||||
t.Fatalf("expected 0 repos, got %d", len(repos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeparatorLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"---", true},
|
||||
{"--------------------------------------------------------------", true},
|
||||
{"--- --- ---", true},
|
||||
{"Name Id Version", false},
|
||||
{"", false},
|
||||
{" ", false},
|
||||
{"--a--", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isSeparatorLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSeparatorLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectColumns(t *testing.T) {
|
||||
header := "Name Id Version Available Source"
|
||||
cols := detectColumns(header)
|
||||
if len(cols) != 5 {
|
||||
t.Fatalf("expected 5 columns, got %d: %v", len(cols), cols)
|
||||
}
|
||||
if cols[0] != 0 {
|
||||
t.Errorf("expected first column at 0, got %d", cols[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFields(t *testing.T) {
|
||||
line := "Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget"
|
||||
cols := []int{0, 34, 67, 82, 93}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) != 5 {
|
||||
t.Fatalf("expected 5 fields, got %d", len(fields))
|
||||
}
|
||||
if fields[0] != "Visual Studio Code" {
|
||||
t.Errorf("field[0] = %q, want 'Visual Studio Code'", fields[0])
|
||||
}
|
||||
if fields[1] != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("field[1] = %q, want 'Microsoft.VisualStudioCode'", fields[1])
|
||||
}
|
||||
if fields[2] != "1.85.0" {
|
||||
t.Errorf("field[2] = %q, want '1.85.0'", fields[2])
|
||||
}
|
||||
if fields[3] != "1.86.0" {
|
||||
t.Errorf("field[3] = %q, want '1.86.0'", fields[3])
|
||||
}
|
||||
if fields[4] != "winget" {
|
||||
t.Errorf("field[4] = %q, want 'winget'", fields[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldsShortLine(t *testing.T) {
|
||||
line := "Git"
|
||||
cols := []int{0, 34, 67}
|
||||
fields := extractFields(line, cols)
|
||||
if fields[0] != "Git" {
|
||||
t.Errorf("field[0] = %q, want 'Git'", fields[0])
|
||||
}
|
||||
if fields[1] != "" {
|
||||
t.Errorf("field[1] = %q, want ''", fields[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFooterLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"3 upgrades available.", true},
|
||||
{"1 package(s) found.", true},
|
||||
{"No installed package found.", true},
|
||||
{"The following packages have upgrades:", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isFooterLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isFooterLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"equal", "1.0.0", "1.0.0", 0},
|
||||
{"less major", "1.0.0", "2.0.0", -1},
|
||||
{"greater major", "2.0.0", "1.0.0", 1},
|
||||
{"less patch", "1.2.3", "1.2.4", -1},
|
||||
{"multi-digit minor", "1.10.0", "1.9.0", 1},
|
||||
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||
{"real versions", "1.85.0", "1.86.0", -1},
|
||||
{"single component", "5", "3", 1},
|
||||
{"empty vs empty", "", "", 0},
|
||||
{"empty vs version", "", "1.0", -1},
|
||||
{"version vs empty", "1.0", "", 1},
|
||||
{"four components", "120.0.6099.130", "120.0.6099.131", -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNonNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"123", "123"},
|
||||
{"123abc", "123"},
|
||||
{"abc", ""},
|
||||
{"0beta", "0"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripNonNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.VersionQuerier = (*Winget)(nil)
|
||||
var _ snack.RepoManager = (*Winget)(nil)
|
||||
var _ snack.NameNormalizer = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=true")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.Clean {
|
||||
t.Error("expected Clean=false")
|
||||
}
|
||||
if caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=false")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
w := New()
|
||||
if w.Name() != "winget" {
|
||||
t.Errorf("Name() = %q, want %q", w.Name(), "winget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Microsoft.VisualStudioCode", "Microsoft.VisualStudioCode"},
|
||||
{" Git.Git ", "Git.Git"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
name, arch := parseArch("Microsoft.VisualStudioCode")
|
||||
if name != "Microsoft.VisualStudioCode" || arch != "" {
|
||||
t.Errorf("parseArch returned (%q, %q), want (%q, %q)",
|
||||
name, arch, "Microsoft.VisualStudioCode", "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripVT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no escapes", "hello", "hello"},
|
||||
{"simple CSI", "\x1b[2Khello", "hello"},
|
||||
{"color code", "\x1b[32mgreen\x1b[0m", "green"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripVT(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripVT(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProgressLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"████████ 50%", true},
|
||||
{"100%", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", false},
|
||||
{"Installing...", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isProgressLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isProgressLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
360
winget/winget_windows.go
Normal file
360
winget/winget_windows.go
Normal file
@@ -0,0 +1,360 @@
|
||||
//go:build windows
|
||||
|
||||
package winget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// commonArgs returns flags used by all winget commands for non-interactive operation.
|
||||
func commonArgs() []string {
|
||||
return []string{
|
||||
"--accept-source-agreements",
|
||||
"--disable-interactivity",
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "winget", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
out := stdout.String()
|
||||
// winget writes progress and VT sequences to stdout; strip them.
|
||||
out = stripProgress(out)
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "Access is denied") ||
|
||||
strings.Contains(out, "administrator") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
combined := se + out
|
||||
if strings.Contains(combined, "No package found") ||
|
||||
strings.Contains(combined, "No installed package found") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrNotFound)
|
||||
}
|
||||
errMsg := strings.TrimSpace(se)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(out)
|
||||
}
|
||||
return "", fmt.Errorf("winget: %s: %w", errMsg, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripProgress removes VT100 escape sequences and progress lines from output.
|
||||
func stripProgress(s string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(s, "\n")
|
||||
for _, line := range lines {
|
||||
clean := stripVT(line)
|
||||
clean = strings.TrimRight(clean, "\r")
|
||||
// Skip pure progress lines (e.g. "██████████████ 50%")
|
||||
if isProgressLine(clean) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(clean)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
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", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if t.FromRepo != "" {
|
||||
args = append(args, "--source", t.FromRepo)
|
||||
} else if o.FromRepo != "" {
|
||||
args = append(args, "--source", o.FromRepo)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget install %s: %w", t.Name, 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)
|
||||
}
|
||||
}
|
||||
for _, t := range toRemove {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.RemoveResult{}, fmt.Errorf("winget uninstall %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var removed []snack.Package
|
||||
for _, t := range toRemove {
|
||||
removed = append(removed, snack.Package{Name: t.Name})
|
||||
}
|
||||
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
for _, t := range pkgs {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent", "--purge"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return fmt.Errorf("winget purge %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
args := []string{"upgrade", "--all", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"source", "update"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget list: %w", err)
|
||||
}
|
||||
return parseTable(out, true), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
args := []string{"search", query}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("winget search: %w", err)
|
||||
}
|
||||
return parseTable(out, false), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("winget info: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
// Check if installed
|
||||
ok, _ := isInstalled(ctx, pkg)
|
||||
p.Installed = ok
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// "No installed package found" is returned as an error by run()
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("winget isInstalled: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
return len(pkgs) > 0, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("winget version: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("winget latestVersion: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"upgrade"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// No upgrades available may exit non-zero on some versions
|
||||
return nil, nil
|
||||
}
|
||||
return parseTable(out, false), 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 strings.EqualFold(u.Name, pkg) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
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 {
|
||||
args := []string{"upgrade", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget 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
|
||||
}
|
||||
|
||||
// sourceList returns configured winget sources.
|
||||
func sourceList(ctx context.Context) ([]snack.Repository, error) {
|
||||
args := []string{"source", "list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget source list: %w", err)
|
||||
}
|
||||
return parseSourceList(out), nil
|
||||
}
|
||||
|
||||
// sourceAdd adds a new winget source.
|
||||
func sourceAdd(ctx context.Context, repo snack.Repository) error {
|
||||
args := []string{"source", "add", "--name", repo.Name, "--arg", repo.URL}
|
||||
args = append(args, commonArgs()...)
|
||||
if repo.Type != "" {
|
||||
args = append(args, "--type", repo.Type)
|
||||
}
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
// sourceRemove removes a winget source.
|
||||
func sourceRemove(ctx context.Context, name string) error {
|
||||
args := []string{"source", "remove", "--name", name}
|
||||
args = append(args, commonArgs()...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user