17 Commits

Author SHA1 Message Date
e8b0454851 Merge pull request #41 from gogrlx/cd/fix-ci-failures
Cd/fix ci failures
2026-03-05 23:23:25 -05:00
2422655c1d Merge pull request #42 from gogrlx/cd/fix-readme-capabilities
docs: fix README capability matrix
2026-03-05 23:23:10 -05:00
4a9a2b1980 docs: fix capability matrix for dnf, flatpak, pkg
- dnf: add Hold and KeyMgmt (both implemented)
- flatpak: remove DryRun (not implemented)
- pkg: remove Hold, RepoMgmt, DryRun (not implemented)
2026-03-06 04:22:35 +00:00
1da329dfb5 docs: fix capability matrix — snap supports Clean 2026-03-06 04:22:35 +00:00
f53534ce6f docs: fix capability matrix for dnf, flatpak, pkg
- dnf: add Hold and KeyMgmt (both implemented)
- flatpak: remove DryRun (not implemented)
- pkg: remove Hold, RepoMgmt, DryRun (not implemented)
2026-03-06 04:21:51 +00:00
d30c9fec0e docs: fix capability matrix — snap supports Clean 2026-03-06 04:20:10 +00:00
98bfc56960 Merge pull request #40 from gogrlx/cd/fix-ci-failures
fix(ci): update integration tests for NameNormalizer, fix lint and Windows CI
2026-03-05 23:15:28 -05:00
c913d96de3 fix(ci): update integration tests for NameNormalizer, fix lint and Windows CI
- Update apk/pacman/snap/flatpak integration tests to assert
  NameNormalize=true (all managers now implement NameNormalizer)
- Update flatpak integration test to assert VersionQuery=true
- Remove unused aur functions: rpcInfoMulti, getAURBuildDir
- Wire AUR.Clean to call clean() for consistency
- Fix Windows CI: add shell:bash to avoid PowerShell arg splitting
  on -coverprofile=coverage-windows.out
2026-03-06 04:13:39 +00:00
ffbe0e12ba Merge branch 'cd/add-winget': add winget support for Windows 2026-03-06 03:40:39 +00:00
6237a5f23a Merge branch 'cd/fix-flatpak-build': fix ports/detect build issues 2026-03-06 03:40:37 +00:00
ac15ab5a49 feat(winget): add integration tests, CI, and README updates
- Add winget_integration_test.go for Windows integration testing
- Add Windows CI job (windows-latest) for unit + detect tests
- Add cross-compile CI matrix (windows, darwin, freebsd, openbsd)
- Update README with winget in supported managers and capability matrix
- Include Windows coverage in codecov upload
2026-03-06 03:40:13 +00:00
9e9fb1a822 ci: add Windows runner and cross-compile jobs for winget
- New 'windows' job runs unit tests on windows-latest with coverage
- New 'cross-compile' matrix job builds for windows/darwin/freebsd/openbsd
- Integration test file for winget (gated behind integration build tag)
- Windows coverage included in codecov upload
2026-03-06 03:29:47 +00:00
aed2ee8b86 feat(winget): add Windows Package Manager support
Implements the full snack.Manager interface for winget:
- Install/Remove/Purge/Upgrade via winget CLI
- Search/List/Info/IsInstalled/Version queries
- Source (repository) management via RepoManager
- Version querying via VersionQuerier
- Targeted package upgrades via PackageUpgrader
- Name normalization via NameNormalizer

All commands use --disable-interactivity, --accept-source-agreements,
and --accept-package-agreements for non-interactive operation.

Parser handles winget's fixed-width tabular output by detecting column
positions from the header/separator lines. Includes VT100 escape
sequence stripping and progress line filtering.

Windows-only via build tags; other platforms return
ErrUnsupportedPlatform. Registered in detect_windows.go as the
default Windows package manager.
2026-03-06 03:26:47 +00:00
84f9cbc9cf fix(ports,detect): remove duplicate funcs and fix windows build
ports/capabilities_openbsd.go duplicated autoremove, clean, fileList,
and owner already defined in ports_openbsd.go, breaking OpenBSD builds.

detect/detect_other.go build tag didn't exclude windows, conflicting
with detect_windows.go declarations.
2026-03-06 03:19:49 +00:00
151c657398 fix(flatpak): restore VersionQuerier functions lost in refactor
The capabilities_linux.go deletion moved VersionQuerier methods
(latestVersion, listUpgrades, upgradeAvailable, versionCmp) to
capabilities.go as receiver methods, but their platform implementations
were not added to flatpak_linux.go, causing build failure on Linux.

Also removes accidentally committed .DS_Store, .crush/, and AGENTS.md.
2026-03-06 03:15:43 +00:00
934c6610c5 feat: add Homebrew provider, implement NameNormalizer across all managers
- Add brew package for Homebrew support on macOS and Linux
- Implement NameNormalizer interface (NormalizeName, ParseArch) for all providers
- Add darwin platform detection with Homebrew as default
- Consolidate capabilities by removing separate *_linux.go/*_other.go files
- Update tests for new capability expectations
- Add comprehensive tests for AUR and brew providers
- Update README with capability matrix and modern Target API usage

💘 Generated with Crush

Assisted-by: AWS Claude Opus 4.5 via Crush <crush@charm.land>
2026-03-05 20:40:32 -05:00
724ecc866e fix(ci): update snap test caps, install Flatseal in flatpak CI
- snap integration test asserted Clean=false, but snap now implements
  Cleaner — updated to assert True
- flatpak CI job now installs Flatseal before running integration tests
  so Version/List tests have a known installed package
2026-03-06 01:14:20 +00:00
73 changed files with 3856 additions and 1032 deletions

View File

@@ -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
View File

@@ -1,2 +1,5 @@
coverage.out
coverage.html
.DS_Store
.crush/
AGENTS.md

149
README.md
View File

@@ -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).

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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")

View File

@@ -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)
)

View File

@@ -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
View 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, ""
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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...)
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
View 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, ""
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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, ""
}

View File

@@ -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()...)
},
}
}

View File

@@ -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
View 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()
}

View File

@@ -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()
}

View File

@@ -1,4 +1,4 @@
//go:build !linux && !freebsd && !openbsd
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
package detect

View File

@@ -1,3 +1,5 @@
//go:build linux
package detect
import (

20
detect/detect_windows.go Normal file
View 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()
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
View 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, ""
}

View 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])
}
}
})
}
}

View File

@@ -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)
)

View File

@@ -1,3 +1,5 @@
//go:build linux
package pacman
import (

16
pacman/normalize.go Normal file
View 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, ""
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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},
}

View File

@@ -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
View 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, ""
}

View File

@@ -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)

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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
View 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, ""
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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
View 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, ""
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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
View 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...)
}

View 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
View 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
View 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
View 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
}