mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-03 21:52:29 -07:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b86a793e1c | |||
|
|
adb8de7bee | ||
| 4ea7c3f93b | |||
| 5863cea51e | |||
| a171459a66 | |||
| 84e4f8e2ff | |||
| 1a51a40e4e | |||
| 6db6e993f0 | |||
| 1410e4888c | |||
| e8b0454851 | |||
| 2422655c1d | |||
| 4a9a2b1980 | |||
| 1da329dfb5 | |||
| f53534ce6f | |||
| d30c9fec0e | |||
| 98bfc56960 | |||
| c913d96de3 | |||
| ffbe0e12ba | |||
| 6237a5f23a | |||
| ac15ab5a49 | |||
| 9e9fb1a822 | |||
| aed2ee8b86 | |||
| 84f9cbc9cf | |||
| 151c657398 | |||
| 934c6610c5 | |||
| 724ecc866e | |||
| b6b50491e2 | |||
| a1d13e8a7d | |||
| c00133718e |
36
.github/workflows/integration.yml
vendored
36
.github/workflows/integration.yml
vendored
@@ -179,6 +179,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak
|
||||
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install -y flathub com.github.tchx84.Flatseal
|
||||
- name: Integration tests
|
||||
run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-flatpak.out ./flatpak/
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -187,10 +188,41 @@ jobs:
|
||||
name: coverage-flatpak
|
||||
path: coverage-flatpak.out
|
||||
|
||||
cross-compile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, darwin, freebsd, openbsd]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build for ${{ matrix.goos }}
|
||||
run: GOOS=${{ matrix.goos }} go build ./...
|
||||
|
||||
windows:
|
||||
name: Windows (winget)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
run: go test -race -coverprofile=coverage-windows.out ./winget/ ./detect/
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.out
|
||||
|
||||
codecov:
|
||||
name: Upload Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak]
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak, windows, cross-compile]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -202,7 +234,7 @@ jobs:
|
||||
run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found"
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out,coverage-windows.out
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
coverage.out
|
||||
coverage.html
|
||||
.DS_Store
|
||||
.crush/
|
||||
AGENTS.md
|
||||
|
||||
@@ -7,9 +7,6 @@ before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
builds:
|
||||
- main: ./cmd/snack/
|
||||
id: snack
|
||||
@@ -44,7 +41,7 @@ archives:
|
||||
nfpms:
|
||||
- id: snack
|
||||
package_name: snack
|
||||
builds: [snack]
|
||||
ids: [snack]
|
||||
formats: [apk, deb, rpm]
|
||||
bindir: /usr/bin
|
||||
description: "A unified CLI for system package managers"
|
||||
@@ -53,6 +50,19 @@ nfpms:
|
||||
homepage: https://github.com/gogrlx/snack
|
||||
vendor: Adatomic, Inc.
|
||||
|
||||
homebrew_casks:
|
||||
- ids: [snack, snack-universal]
|
||||
name: snack
|
||||
binaries:
|
||||
- snack
|
||||
repository:
|
||||
owner: gogrlx
|
||||
name: homebrew-tap
|
||||
directory: Casks
|
||||
homepage: https://github.com/gogrlx/snack
|
||||
description: "A unified CLI for system package managers"
|
||||
license: 0BSD
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: gogrlx
|
||||
|
||||
149
README.md
149
README.md
@@ -11,22 +11,38 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
|
||||
|
||||
## Supported Package Managers
|
||||
|
||||
| Package | Manager | Platform | Extras |
|
||||
| Package | Manager | Platform | Status |
|
||||
|---------|---------|----------|--------|
|
||||
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | DryRun, FileOwner, Holder, KeyManager, NameNormalizer, RepoManager |
|
||||
| `dpkg` | dpkg | Debian/Ubuntu | DryRun, FileOwner, NameNormalizer |
|
||||
| `dnf` | DNF 4/5 | Fedora/RHEL | DryRun, FileOwner, Grouper, Holder, KeyManager, NameNormalizer, RepoManager |
|
||||
| `rpm` | RPM | Fedora/RHEL | FileOwner, NameNormalizer |
|
||||
| `pacman` | pacman | Arch Linux | DryRun, FileOwner, Grouper |
|
||||
| `aur` | AUR (makepkg) | Arch Linux | — |
|
||||
| `apk` | apk-tools | Alpine Linux | DryRun, FileOwner |
|
||||
| `flatpak` | Flatpak | Cross-distro | RepoManager |
|
||||
| `snap` | snapd | Cross-distro | — |
|
||||
| `pkg` | pkg(8) | FreeBSD | FileOwner |
|
||||
| `ports` | ports/packages | OpenBSD | FileOwner |
|
||||
| `detect` | Auto-detection | All | — |
|
||||
| `pacman` | pacman | Arch Linux | ✅ |
|
||||
| `aur` | AUR (RPC + makepkg) | Arch Linux | ✅ |
|
||||
| `apk` | apk-tools | Alpine Linux | ✅ |
|
||||
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | ✅ |
|
||||
| `dpkg` | dpkg | Debian/Ubuntu | ✅ |
|
||||
| `dnf` | DNF | Fedora/RHEL | ✅ |
|
||||
| `rpm` | RPM | Fedora/RHEL | ✅ |
|
||||
| `flatpak` | Flatpak | Linux | ✅ |
|
||||
| `snap` | snapd | Linux | ✅ |
|
||||
| `brew` | Homebrew | macOS/Linux | ✅ |
|
||||
| `pkg` | pkg(8) | FreeBSD | ✅ |
|
||||
| `ports` | ports/packages | OpenBSD | ✅ |
|
||||
| `winget` | Windows Package Manager | Windows | ✅ |
|
||||
| `detect` | Auto-detection | All | ✅ |
|
||||
|
||||
All providers implement `Manager`, `VersionQuerier`, `Cleaner`, and `PackageUpgrader`. The **Extras** column lists additional capabilities beyond that baseline.
|
||||
### Capability Matrix
|
||||
|
||||
| Provider | VersionQuery | Hold | Clean | FileOwner | RepoMgmt | KeyMgmt | Groups | NameNorm | DryRun | PkgUpgrade |
|
||||
|----------|:------------:|:----:|:-----:|:---------:|:--------:|:-------:|:------:|:--------:|:------:|:----------:|
|
||||
| apt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ |
|
||||
| pacman | ✅ | - | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | ✅ |
|
||||
| aur | ✅ | - | ✅ | - | - | - | - | - | - | ✅ |
|
||||
| apk | ✅ | - | ✅ | ✅ | - | - | - | ✅ | ✅ | ✅ |
|
||||
| dnf | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| flatpak | ✅ | - | ✅ | - | ✅ | - | - | ✅ | - | ✅ |
|
||||
| snap | ✅ | - | ✅ | - | - | - | - | ✅ | - | ✅ |
|
||||
| brew | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| pkg | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| ports | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| winget | ✅ | - | - | - | ✅ | - | - | ✅ | - | ✅ |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -52,12 +68,11 @@ func main() {
|
||||
ctx := context.Background()
|
||||
mgr := apt.New()
|
||||
|
||||
// Install packages
|
||||
result, err := mgr.Install(ctx, snack.Targets("nginx", "curl"), snack.WithSudo(), snack.WithAssumeYes())
|
||||
// Install a package
|
||||
_, err := mgr.Install(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Installed: %d, Unchanged: %d\n", len(result.Installed), len(result.Unchanged))
|
||||
|
||||
// Check if installed
|
||||
installed, err := mgr.IsInstalled(ctx, "nginx")
|
||||
@@ -65,14 +80,6 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("nginx installed:", installed)
|
||||
|
||||
// Upgrade specific packages
|
||||
if up, ok := mgr.(snack.PackageUpgrader); ok {
|
||||
_, err := up.UpgradePackages(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,11 +93,6 @@ if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Detected:", mgr.Name())
|
||||
|
||||
// All available managers
|
||||
for _, m := range detect.All() {
|
||||
fmt.Println(m.Name())
|
||||
}
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
@@ -99,21 +101,17 @@ snack uses a layered interface design. Every provider implements `Manager` (the
|
||||
|
||||
```go
|
||||
// Base — every provider
|
||||
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
|
||||
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
|
||||
|
||||
// Core optional — implemented by all providers
|
||||
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
|
||||
snack.Cleaner // Autoremove, Clean (orphan/cache cleanup)
|
||||
snack.PackageUpgrader // UpgradePackages (upgrade specific packages)
|
||||
|
||||
// Provider-specific — type-assert to check
|
||||
snack.Holder // Hold, Unhold, ListHeld, IsHeld (version pinning)
|
||||
snack.FileOwner // FileList, Owner (file-to-package queries)
|
||||
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
|
||||
snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys)
|
||||
snack.Grouper // GroupList, GroupInfo, GroupInstall, GroupIsInstalled
|
||||
snack.NameNormalizer // NormalizeName, ParseArch
|
||||
snack.DryRunner // SupportsDryRun (honors WithDryRun option)
|
||||
// Optional capabilities — type-assert to check
|
||||
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
|
||||
snack.Holder // Hold, Unhold, ListHeld (version pinning)
|
||||
snack.Cleaner // Autoremove, Clean (orphan/cache cleanup)
|
||||
snack.FileOwner // FileList, Owner (file-to-package queries)
|
||||
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
|
||||
snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys)
|
||||
snack.Grouper // GroupList, GroupInfo, GroupInstall
|
||||
snack.NameNormalizer // NormalizeName, ParseArch
|
||||
```
|
||||
|
||||
Check capabilities at runtime:
|
||||
@@ -123,49 +121,8 @@ caps := snack.GetCapabilities(mgr)
|
||||
if caps.Hold {
|
||||
mgr.(snack.Holder).Hold(ctx, []string{"nginx"})
|
||||
}
|
||||
if caps.FileOwnership {
|
||||
owner, _ := mgr.(snack.FileOwner).Owner(ctx, "/usr/bin/curl")
|
||||
fmt.Println("Owned by:", owner)
|
||||
}
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
All mutating operations accept functional options:
|
||||
|
||||
```go
|
||||
snack.WithSudo() // prepend sudo
|
||||
snack.WithAssumeYes() // auto-confirm prompts
|
||||
snack.WithDryRun() // simulate (if DryRunner)
|
||||
snack.WithVerbose() // verbose output
|
||||
snack.WithRefresh() // refresh index before operation
|
||||
snack.WithReinstall() // reinstall even if current
|
||||
snack.WithRoot("/mnt") // alternate root filesystem
|
||||
snack.WithFromRepo("sid") // install from specific repository
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
A companion CLI is included at `cmd/snack`:
|
||||
|
||||
```bash
|
||||
snack install nginx curl # install packages
|
||||
snack remove nginx # remove packages
|
||||
snack upgrade # upgrade all packages
|
||||
snack update # refresh package index
|
||||
snack search redis # search for packages
|
||||
snack info nginx # show package details
|
||||
snack list # list installed packages
|
||||
snack which /usr/bin/curl # find owning package
|
||||
snack hold nginx # pin package version
|
||||
snack unhold nginx # unpin package version
|
||||
snack clean # autoremove + clean cache
|
||||
snack detect # show detected managers + capabilities
|
||||
snack version # show version
|
||||
```
|
||||
|
||||
Global flags: `--manager <name>`, `--sudo`, `--yes`, `--dry-run`
|
||||
|
||||
## Design
|
||||
|
||||
- **Thin CLI wrappers** — each sub-package wraps a package manager's CLI tools. No FFI, no library bindings.
|
||||
@@ -176,6 +133,28 @@ Global flags: `--manager <name>`, `--sudo`, `--yes`, `--dry-run`
|
||||
- **Platform-safe** — build tags ensure packages compile everywhere but only run where appropriate.
|
||||
- **No root assumption** — use `snack.WithSudo()` when elevated privileges are needed.
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. pacman + AUR (Arch Linux)
|
||||
2. apk (Alpine Linux)
|
||||
3. apt + dpkg (Debian/Ubuntu)
|
||||
4. dnf + rpm (Fedora/RHEL)
|
||||
5. flatpak + snap (cross-distro)
|
||||
6. pkg + ports (BSD)
|
||||
7. winget (Windows)
|
||||
|
||||
## CLI
|
||||
|
||||
A companion CLI tool is planned for direct terminal usage:
|
||||
|
||||
```bash
|
||||
snack install nginx
|
||||
snack remove nginx
|
||||
snack search redis
|
||||
snack list
|
||||
snack upgrade
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
0BSD — see [LICENSE](LICENSE).
|
||||
|
||||
19
apk/apk.go
19
apk/apk.go
@@ -17,9 +17,12 @@ func New() *Apk {
|
||||
return &Apk{}
|
||||
}
|
||||
|
||||
// compile-time check
|
||||
var _ snack.Manager = (*Apk)(nil)
|
||||
var _ snack.PackageUpgrader = (*Apk)(nil)
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Apk)(nil)
|
||||
_ snack.PackageUpgrader = (*Apk)(nil)
|
||||
_ snack.NameNormalizer = (*Apk)(nil)
|
||||
)
|
||||
|
||||
// Name returns "apk".
|
||||
func (a *Apk) Name() string { return "apk" }
|
||||
@@ -93,3 +96,13 @@ func (a *Apk) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...
|
||||
defer a.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (a *Apk) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (a *Apk) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Apk(t *testing.T) {
|
||||
assert.False(t, caps.RepoManagement, "apk should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "apk should not support KeyManagement")
|
||||
assert.False(t, caps.Groups, "apk should not support Groups")
|
||||
assert.False(t, caps.NameNormalize, "apk should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "apk should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestSplitNameVersion(t *testing.T) {
|
||||
{"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"},
|
||||
{"a-b-c-3.0", "a-b-c", "3.0"},
|
||||
{"single", "single", ""},
|
||||
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
|
||||
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
|
||||
{"pkg-0", "pkg", "0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -289,10 +289,10 @@ func TestParseInfoEdgeCases(t *testing.T) {
|
||||
|
||||
func TestParseInfoNameVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantN string
|
||||
wantV string
|
||||
name string
|
||||
input string
|
||||
wantN string
|
||||
wantV string
|
||||
}{
|
||||
{
|
||||
name: "standard",
|
||||
@@ -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")
|
||||
@@ -435,8 +435,8 @@ func TestParseUpgradeSimulation(t *testing.T) {
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single upgrade",
|
||||
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
|
||||
name: "single upgrade",
|
||||
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "curl", Version: "8.6.0-r0", Installed: true},
|
||||
@@ -462,8 +462,8 @@ OK: 123 MiB in 45 packages
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "upgrade without version parens",
|
||||
input: "(1/1) Upgrading busybox\n",
|
||||
name: "upgrade without version parens",
|
||||
input: "(1/1) Upgrading busybox\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "busybox", Version: "", Installed: true},
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Apk)(nil)
|
||||
_ snack.Cleaner = (*Apk)(nil)
|
||||
_ snack.FileOwner = (*Apk)(nil)
|
||||
_ snack.NameNormalizer = (*Apk)(nil)
|
||||
_ snack.DryRunner = (*Apk)(nil)
|
||||
)
|
||||
|
||||
|
||||
@@ -44,45 +44,6 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return parseUpgradeSimulation(string(out)), nil
|
||||
}
|
||||
|
||||
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
|
||||
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
func parseUpgradeSimulation(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "Upgrading") {
|
||||
continue
|
||||
}
|
||||
// "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
idx := strings.Index(line, "Upgrading ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[idx+len("Upgrading "):]
|
||||
// "pkg (oldver -> newver)"
|
||||
parts := strings.SplitN(rest, " (", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
var ver string
|
||||
if len(parts) == 2 {
|
||||
// "oldver -> newver)"
|
||||
verPart := strings.TrimSuffix(parts[1], ")")
|
||||
arrow := strings.Split(verPart, " -> ")
|
||||
if len(arrow) == 2 {
|
||||
ver = strings.TrimSpace(arrow[1])
|
||||
}
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: name,
|
||||
Version: ver,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
|
||||
38
apk/normalize.go
Normal file
38
apk/normalize.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package apk
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Alpine package names sometimes include version suffixes in queries.
|
||||
// This strips common version patterns.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseArchNormalize(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// Alpine package names typically don't embed architecture in the package name,
|
||||
// but some query outputs may include it. Common patterns:
|
||||
// - package-x86_64
|
||||
// - package-aarch64
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
knownArchs := map[string]bool{
|
||||
"x86_64": true,
|
||||
"x86": true,
|
||||
"aarch64": true,
|
||||
"armhf": true,
|
||||
"armv7": true,
|
||||
"ppc64le": true,
|
||||
"s390x": true,
|
||||
"riscv64": true,
|
||||
"loongarch64": true,
|
||||
}
|
||||
|
||||
if idx := strings.LastIndex(name, "-"); idx >= 0 {
|
||||
suffix := name[idx+1:]
|
||||
if knownArchs[suffix] {
|
||||
return name[:idx], suffix
|
||||
}
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
71
apk/normalize_test.go
Normal file
71
apk/normalize_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package apk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"curl", "curl"},
|
||||
{"curl-x86_64", "curl"},
|
||||
{"openssl-aarch64", "openssl"},
|
||||
{"musl-armhf", "musl"},
|
||||
{"busybox-armv7", "busybox"},
|
||||
{"lib-ssl-dev-x86", "lib-ssl-dev"},
|
||||
{"zlib-ppc64le", "zlib"},
|
||||
{"kernel-s390x", "kernel"},
|
||||
{"toolchain-riscv64", "toolchain"},
|
||||
{"app-loongarch64", "app"},
|
||||
// No arch suffix — unchanged
|
||||
{"python", "python"},
|
||||
{"go", "go"},
|
||||
{"", ""},
|
||||
// Suffix that isn't an arch — unchanged
|
||||
{"my-pkg-foo", "my-pkg-foo"},
|
||||
{"libfoo-1.0", "libfoo-1.0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"x86_64", "curl-x86_64", "curl", "x86_64"},
|
||||
{"x86", "musl-x86", "musl", "x86"},
|
||||
{"aarch64", "openssl-aarch64", "openssl", "aarch64"},
|
||||
{"armhf", "busybox-armhf", "busybox", "armhf"},
|
||||
{"armv7", "lib-armv7", "lib", "armv7"},
|
||||
{"ppc64le", "app-ppc64le", "app", "ppc64le"},
|
||||
{"s390x", "pkg-s390x", "pkg", "s390x"},
|
||||
{"riscv64", "tool-riscv64", "tool", "riscv64"},
|
||||
{"loongarch64", "gcc-loongarch64", "gcc", "loongarch64"},
|
||||
{"no arch", "curl", "curl", ""},
|
||||
{"unknown suffix", "pkg-foobar", "pkg-foobar", ""},
|
||||
{"empty", "", "", ""},
|
||||
{"hyphen but not arch", "lib-ssl-dev", "lib-ssl-dev", ""},
|
||||
{"multi hyphen with arch", "lib-ssl-dev-x86_64", "lib-ssl-dev", "x86_64"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotArch := parseArchNormalize(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("parseArchNormalize(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
39
apk/parse.go
39
apk/parse.go
@@ -136,3 +136,42 @@ func parseInfoNameVersion(output string) (string, string) {
|
||||
}
|
||||
return splitNameVersion(fields[0])
|
||||
}
|
||||
|
||||
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
|
||||
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
func parseUpgradeSimulation(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "Upgrading") {
|
||||
continue
|
||||
}
|
||||
// "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
idx := strings.Index(line, "Upgrading ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[idx+len("Upgrading "):]
|
||||
// "pkg (oldver -> newver)"
|
||||
parts := strings.SplitN(rest, " (", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
var ver string
|
||||
if len(parts) == 2 {
|
||||
// "oldver -> newver)"
|
||||
verPart := strings.TrimSuffix(parts[1], ")")
|
||||
arrow := strings.Split(verPart, " -> ")
|
||||
if len(arrow) == 2 {
|
||||
ver = strings.TrimSpace(arrow[1])
|
||||
}
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: name,
|
||||
Version: ver,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
@@ -191,7 +191,6 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
|
||||
func addRepo(ctx context.Context, repo snack.Repository) error {
|
||||
repoLine := repo.URL
|
||||
if repo.Type != "" {
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestParseList_EdgeCases(t *testing.T) {
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"whitespace_only", " \n \n ", 0},
|
||||
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
|
||||
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
|
||||
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
|
||||
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
|
||||
{"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1},
|
||||
{"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2},
|
||||
{"trailing_newline", "bash\t5.2-1\n", 1},
|
||||
|
||||
@@ -69,19 +69,29 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
|
||||
}
|
||||
}
|
||||
|
||||
pkgFile, err := a.buildPackage(ctx, t)
|
||||
pkgFile, cleanupDir, err := a.buildPackage(ctx, t)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
|
||||
}
|
||||
|
||||
if o.DryRun {
|
||||
if cleanupDir != "" {
|
||||
os.RemoveAll(cleanupDir)
|
||||
}
|
||||
installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"})
|
||||
continue
|
||||
}
|
||||
|
||||
args := []string{"-U", "--noconfirm", pkgFile}
|
||||
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
|
||||
installErr := func() error {
|
||||
if cleanupDir != "" {
|
||||
defer os.RemoveAll(cleanupDir)
|
||||
}
|
||||
_, err := runPacman(ctx, args, o.Sudo)
|
||||
return err
|
||||
}()
|
||||
if installErr != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, installErr)
|
||||
}
|
||||
|
||||
v, _ := version(ctx, t.Name)
|
||||
@@ -97,23 +107,25 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Returns the path to the built .pkg.tar.zst file and an optional cleanup
|
||||
// directory (non-empty only when a temp dir was created; caller must remove it).
|
||||
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (pkgPath string, cleanupDir string, err error) {
|
||||
// Determine build directory
|
||||
buildDir := a.BuildDir
|
||||
if buildDir == "" {
|
||||
tmp, err := os.MkdirTemp("", "snack-aur-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating temp dir: %w", err)
|
||||
return "", "", fmt.Errorf("creating temp dir: %w", err)
|
||||
}
|
||||
buildDir = tmp
|
||||
cleanupDir = tmp
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(buildDir, t.Name)
|
||||
|
||||
// Clone or update the PKGBUILD repo
|
||||
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
|
||||
return "", err
|
||||
return "", cleanupDir, err
|
||||
}
|
||||
|
||||
// Run makepkg
|
||||
@@ -125,15 +137,15 @@ func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error)
|
||||
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)
|
||||
return "", cleanupDir, fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
|
||||
// Find the built package file
|
||||
matches, err := filepath.Glob(filepath.Join(pkgDir, "*.pkg.tar*"))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return "", fmt.Errorf("makepkg %s: no package file produced", t.Name)
|
||||
return "", cleanupDir, fmt.Errorf("makepkg %s: no package file produced", t.Name)
|
||||
}
|
||||
return matches[len(matches)-1], nil
|
||||
return matches[len(matches)-1], cleanupDir, nil
|
||||
}
|
||||
|
||||
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.
|
||||
|
||||
141
aur/aur_test.go
141
aur/aur_test.go
@@ -3,9 +3,7 @@ package aur
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePackageList(t *testing.T) {
|
||||
@@ -65,142 +63,3 @@ func TestNewWithOptions(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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, false},
|
||||
{"Hold", caps.Hold, 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 {
|
||||
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 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"},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
156
brew/brew.go
Normal file
156
brew/brew.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package brew provides Go bindings for Homebrew package manager.
|
||||
package brew
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Brew wraps the brew CLI.
|
||||
type Brew struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Brew manager.
|
||||
func New() *Brew {
|
||||
return &Brew{}
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
_ snack.PackageUpgrader = (*Brew)(nil)
|
||||
)
|
||||
|
||||
// Name returns "brew".
|
||||
func (b *Brew) Name() string { return "brew" }
|
||||
|
||||
// Available reports whether brew is present on the system.
|
||||
func (b *Brew) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (b *Brew) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (b *Brew) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including all dependencies.
|
||||
func (b *Brew) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (b *Brew) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the package index.
|
||||
func (b *Brew) Update(ctx context.Context) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (b *Brew) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries Homebrew for packages.
|
||||
func (b *Brew) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (b *Brew) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (b *Brew) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (b *Brew) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (b *Brew) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
// Homebrew does not embed architecture in package names.
|
||||
func (b *Brew) ParseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
|
||||
// LatestVersion returns the latest available version of a package.
|
||||
func (b *Brew) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (b *Brew) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (b *Brew) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (b *Brew) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove removes packages that were installed as dependencies but are no longer needed.
|
||||
func (b *Brew) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return autoremove(ctx, opts...)
|
||||
}
|
||||
|
||||
// Clean removes cached package files.
|
||||
func (b *Brew) Clean(ctx context.Context) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return clean(ctx)
|
||||
}
|
||||
|
||||
// FileList returns all files installed by a package.
|
||||
func (b *Brew) FileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
return fileList(ctx, pkg)
|
||||
}
|
||||
|
||||
// Owner returns the package that owns a given file path.
|
||||
func (b *Brew) Owner(ctx context.Context, path string) (string, error) {
|
||||
return owner(ctx, path)
|
||||
}
|
||||
|
||||
// UpgradePackages upgrades specific installed packages.
|
||||
func (b *Brew) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
87
brew/brew_other.go
Normal file
87
brew/brew_other.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build !darwin && !linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
|
||||
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func fileList(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func owner(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
223
brew/brew_test.go
Normal file
223
brew/brew_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
b := New()
|
||||
if b == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
b := New()
|
||||
if b.Name() != "brew" {
|
||||
t.Errorf("Name() = %q, want %q", b.Name(), "brew")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList(t *testing.T) {
|
||||
input := `git 2.43.0
|
||||
go 1.21.6
|
||||
vim 9.1.0
|
||||
`
|
||||
pkgs := parseBrewList(input)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "git" || pkgs[0].Version != "2.43.0" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList_Empty(t *testing.T) {
|
||||
pkgs := parseBrewList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList_SinglePackage(t *testing.T) {
|
||||
input := "curl 8.6.0\n"
|
||||
pkgs := parseBrewList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" {
|
||||
t.Errorf("expected name=curl, got %q", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewSearch(t *testing.T) {
|
||||
input := `==> Formulae
|
||||
git
|
||||
git-absorb
|
||||
git-annex
|
||||
==> Casks
|
||||
git-credential-manager
|
||||
`
|
||||
pkgs := parseBrewSearch(input)
|
||||
if len(pkgs) != 4 {
|
||||
t.Fatalf("expected 4 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "git" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewSearch_Empty(t *testing.T) {
|
||||
pkgs := parseBrewSearch("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo(t *testing.T) {
|
||||
input := `{"formulae":[{"name":"git","full_name":"git","desc":"Distributed revision control system","versions":{"stable":"2.43.0"},"installed":[{"version":"2.43.0"}]}],"casks":[]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "git" {
|
||||
t.Errorf("expected name 'git', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "2.43.0" {
|
||||
t.Errorf("unexpected version: %q", pkg.Version)
|
||||
}
|
||||
if !pkg.Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo_NotInstalled(t *testing.T) {
|
||||
input := `{"formulae":[{"name":"wget","full_name":"wget","desc":"Internet file retriever","versions":{"stable":"1.21"},"installed":[]}],"casks":[]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Installed {
|
||||
t.Error("expected Installed=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo_Cask(t *testing.T) {
|
||||
input := `{"formulae":[],"casks":[{"token":"visual-studio-code","name":["Visual Studio Code"],"desc":"Open-source code editor","version":"1.85.0"}]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "visual-studio-code" {
|
||||
t.Errorf("expected name 'visual-studio-code', got %q", pkg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"git", "git"},
|
||||
{"python@3.12", "python"},
|
||||
{"node@18", "node"},
|
||||
{"go", "go"},
|
||||
{"ruby@3.2", "ruby"},
|
||||
}
|
||||
|
||||
b := New()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := b.NormalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"git", "git", ""},
|
||||
// Homebrew doesn't embed arch in names - @ is version suffix
|
||||
{"python@3.12", "python@3.12", ""},
|
||||
{"node@18", "node@18", ""},
|
||||
}
|
||||
|
||||
b := New()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
gotName, gotArch := b.ParseArch(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("ParseArch(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got bool
|
||||
want bool
|
||||
}{
|
||||
{"VersionQuery", caps.VersionQuery, true},
|
||||
{"Clean", caps.Clean, true},
|
||||
{"FileOwnership", caps.FileOwnership, true},
|
||||
{"NameNormalize", caps.NameNormalize, true},
|
||||
// Homebrew does not support these
|
||||
{"Hold", caps.Hold, false},
|
||||
{"RepoManagement", caps.RepoManagement, false},
|
||||
{"KeyManagement", caps.KeyManagement, false},
|
||||
{"Groups", caps.Groups, false},
|
||||
{"DryRun", caps.DryRun, false},
|
||||
{"PackageUpgrade", caps.PackageUpgrade, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNonCompliance(t *testing.T) {
|
||||
var m snack.Manager = New()
|
||||
if _, ok := m.(snack.Holder); ok {
|
||||
t.Error("Brew should not implement Holder")
|
||||
}
|
||||
if _, ok := m.(snack.RepoManager); ok {
|
||||
t.Error("Brew should not implement RepoManager")
|
||||
}
|
||||
if _, ok := m.(snack.KeyManager); ok {
|
||||
t.Error("Brew should not implement KeyManager")
|
||||
}
|
||||
if _, ok := m.(snack.Grouper); ok {
|
||||
t.Error("Brew should not implement Grouper")
|
||||
}
|
||||
}
|
||||
473
brew/brew_unix.go
Normal file
473
brew/brew_unix.go
Normal file
@@ -0,0 +1,473 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("brew")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "brew", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "permission denied") {
|
||||
return "", fmt.Errorf("brew: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("brew: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toInstall []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.Reinstall || t.Version != "" || o.DryRun {
|
||||
toInstall = append(toInstall, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toInstall = append(toInstall, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toInstall {
|
||||
args := []string{"install"}
|
||||
pkg := t.Name
|
||||
if t.Version != "" {
|
||||
pkg = t.Name + "@" + t.Version
|
||||
}
|
||||
args = append(args, pkg)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
}
|
||||
var installed []snack.Package
|
||||
for _, t := range toInstall {
|
||||
v, _ := version(ctx, t.Name)
|
||||
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toRemove []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toRemove = append(toRemove, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toRemove = append(toRemove, t)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
args := append([]string{"uninstall"}, snack.TargetNames(toRemove)...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
}
|
||||
var removed []snack.Package
|
||||
for _, t := range toRemove {
|
||||
removed = append(removed, snack.Package{Name: t.Name})
|
||||
}
|
||||
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"uninstall", "--zap"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"upgrade"})
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"update"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list", "--versions"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brew list: %w", err)
|
||||
}
|
||||
return parseBrewList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"search", query})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No formulae or casks found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("brew search: %w", err)
|
||||
}
|
||||
return parseBrewSearch(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"info", "--json=v2", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No available formula") ||
|
||||
strings.Contains(err.Error(), "No formulae or casks found") {
|
||||
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("brew info: %w", err)
|
||||
}
|
||||
p := parseBrewInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "brew", "list", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("brew isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"list", "--versions", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("brew version: %w", err)
|
||||
}
|
||||
pkgs := parseBrewList(out)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"info", "--json=v2", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No available formula") {
|
||||
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("brew latestVersion: %w", err)
|
||||
}
|
||||
ver := parseBrewInfoVersion(out)
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"outdated", "--json=v2"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brew listUpgrades: %w", err)
|
||||
}
|
||||
return parseBrewOutdated(out), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range upgrades {
|
||||
if u.Name == pkg {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"autoremove"})
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"cleanup"})
|
||||
return err
|
||||
}
|
||||
|
||||
// brewInfoJSON represents the JSON output from `brew info --json=v2`.
|
||||
type brewInfoJSON struct {
|
||||
Formulae []struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Desc string `json:"desc"`
|
||||
Versions struct {
|
||||
Stable string `json:"stable"`
|
||||
} `json:"versions"`
|
||||
Installed []struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"installed"`
|
||||
} `json:"formulae"`
|
||||
Casks []struct {
|
||||
Token string `json:"token"`
|
||||
Name []string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Version string `json:"version"`
|
||||
} `json:"casks"`
|
||||
}
|
||||
|
||||
// brewOutdatedJSON represents the JSON output from `brew outdated --json=v2`.
|
||||
type brewOutdatedJSON struct {
|
||||
Formulae []struct {
|
||||
Name string `json:"name"`
|
||||
InstalledVersions []string `json:"installed_versions"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
} `json:"formulae"`
|
||||
Casks []struct {
|
||||
Name string `json:"name"`
|
||||
InstalledVersions string `json:"installed_versions"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
} `json:"casks"`
|
||||
}
|
||||
|
||||
func parseBrewList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 1 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Installed: true,
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
pkg.Version = fields[1]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func parseBrewSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "==>") {
|
||||
continue
|
||||
}
|
||||
for _, name := range strings.Fields(line) {
|
||||
pkgs = append(pkgs, snack.Package{Name: name})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func parseBrewInfo(output string) *snack.Package {
|
||||
var data brewInfoJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(data.Formulae) > 0 {
|
||||
f := data.Formulae[0]
|
||||
pkg := &snack.Package{
|
||||
Name: f.Name,
|
||||
Version: f.Versions.Stable,
|
||||
Description: f.Desc,
|
||||
}
|
||||
if len(f.Installed) > 0 {
|
||||
pkg.Installed = true
|
||||
pkg.Version = f.Installed[0].Version
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
if len(data.Casks) > 0 {
|
||||
c := data.Casks[0]
|
||||
return &snack.Package{
|
||||
Name: c.Token,
|
||||
Version: c.Version,
|
||||
Description: c.Desc,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBrewInfoVersion(output string) string {
|
||||
var data brewInfoJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(data.Formulae) > 0 {
|
||||
return data.Formulae[0].Versions.Stable
|
||||
}
|
||||
if len(data.Casks) > 0 {
|
||||
return data.Casks[0].Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBrewOutdated(output string) []snack.Package {
|
||||
var data brewOutdatedJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
var pkgs []snack.Package
|
||||
for _, f := range data.Formulae {
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: f.Name,
|
||||
Version: f.CurrentVersion,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
for _, c := range data.Casks {
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: c.Name,
|
||||
Version: c.CurrentVersion,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func semverCmp(a, b string) int {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
|
||||
maxLen := len(partsA)
|
||||
if len(partsB) > maxLen {
|
||||
maxLen = len(partsB)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var numA, numB int
|
||||
if i < len(partsA) {
|
||||
fmt.Sscanf(partsA[i], "%d", &numA)
|
||||
}
|
||||
if i < len(partsB) {
|
||||
fmt.Sscanf(partsB[i], "%d", &numB)
|
||||
}
|
||||
if numA < numB {
|
||||
return -1
|
||||
}
|
||||
if numA > numB {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := run(ctx, []string{"list", "--formula", pkg})
|
||||
if err != nil {
|
||||
// Try cask
|
||||
out, err = run(ctx, []string{"list", "--cask", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("brew fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("brew fileList: %w", err)
|
||||
}
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
// brew doesn't have a direct "which package owns this file" command
|
||||
// We need to iterate through installed packages and check their files
|
||||
out, err := run(ctx, []string{"list", "--formula"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("brew owner: %w", err)
|
||||
}
|
||||
for _, pkg := range strings.Fields(out) {
|
||||
files, err := fileList(ctx, pkg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range files {
|
||||
if f == path || strings.HasSuffix(f, "/"+path) {
|
||||
return pkg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("brew owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
if _, err := run(ctx, []string{"upgrade", t.Name}); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("brew upgrade %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
for _, t := range toUpgrade {
|
||||
v, _ := version(ctx, t.Name)
|
||||
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
|
||||
}
|
||||
12
brew/capabilities.go
Normal file
12
brew/capabilities.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package brew
|
||||
|
||||
import "github.com/gogrlx/snack"
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
)
|
||||
21
brew/normalize.go
Normal file
21
brew/normalize.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package brew
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Homebrew formulae can have version suffixes like `python@3.12`.
|
||||
// This strips the version suffix to get the base formula name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseVersionSuffix(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseVersionSuffix extracts the version suffix from a formula name.
|
||||
// Homebrew uses @ to denote versioned formulae (e.g., "python@3.12").
|
||||
// Returns the name without version and the version string.
|
||||
func parseVersionSuffix(name string) (string, string) {
|
||||
if idx := strings.LastIndex(name, "@"); idx > 0 {
|
||||
return name[:idx], name[idx+1:]
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
@@ -4,16 +4,16 @@ package snack
|
||||
// Useful for grlx to determine what operations are available before
|
||||
// attempting them.
|
||||
type Capabilities struct {
|
||||
VersionQuery bool
|
||||
Hold bool
|
||||
Clean bool
|
||||
FileOwnership bool
|
||||
RepoManagement bool
|
||||
KeyManagement bool
|
||||
Groups bool
|
||||
NameNormalize bool
|
||||
DryRun bool
|
||||
PackageUpgrade bool
|
||||
VersionQuery bool
|
||||
Hold bool
|
||||
Clean bool
|
||||
FileOwnership bool
|
||||
RepoManagement bool
|
||||
KeyManagement bool
|
||||
Groups bool
|
||||
NameNormalize bool
|
||||
DryRun bool
|
||||
PackageUpgrade bool
|
||||
}
|
||||
|
||||
// GetCapabilities probes a Manager for all optional interface support.
|
||||
@@ -29,15 +29,15 @@ func GetCapabilities(m Manager) Capabilities {
|
||||
_, dr := m.(DryRunner)
|
||||
_, pu := m.(PackageUpgrader)
|
||||
return Capabilities{
|
||||
VersionQuery: vq,
|
||||
Hold: h,
|
||||
Clean: c,
|
||||
FileOwnership: fo,
|
||||
RepoManagement: rm,
|
||||
KeyManagement: km,
|
||||
Groups: g,
|
||||
NameNormalize: nn,
|
||||
DryRun: dr,
|
||||
PackageUpgrade: pu,
|
||||
VersionQuery: vq,
|
||||
Hold: h,
|
||||
Clean: c,
|
||||
FileOwnership: fo,
|
||||
RepoManagement: rm,
|
||||
KeyManagement: km,
|
||||
Groups: g,
|
||||
NameNormalize: nn,
|
||||
DryRun: dr,
|
||||
PackageUpgrade: pu,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@ func (m *mockManager) Remove(context.Context, []snack.Target, ...snack.Option) (
|
||||
return snack.RemoveResult{}, nil
|
||||
}
|
||||
func (m *mockManager) Purge(context.Context, []snack.Target, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Update(context.Context) error { return nil }
|
||||
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
|
||||
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
|
||||
func (m *mockManager) Available() bool { return true }
|
||||
func (m *mockManager) Name() string { return "mock" }
|
||||
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Update(context.Context) error { return nil }
|
||||
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
|
||||
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
|
||||
func (m *mockManager) Available() bool { return true }
|
||||
func (m *mockManager) Name() string { return "mock" }
|
||||
|
||||
// fullMockManager implements Manager plus all optional interfaces.
|
||||
type fullMockManager struct {
|
||||
|
||||
@@ -47,6 +47,9 @@ behind a single, consistent interface.`,
|
||||
holdCmd(),
|
||||
unholdCmd(),
|
||||
cleanCmd(),
|
||||
repoCmd(),
|
||||
keyCmd(),
|
||||
groupCmd(),
|
||||
detectCmd(),
|
||||
versionCmd(),
|
||||
)
|
||||
@@ -412,3 +415,237 @@ func versionCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "repo",
|
||||
Short: "Manage package repositories",
|
||||
}
|
||||
cmd.AddCommand(repoListCmd(), repoAddCmd(), repoRemoveCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func repoListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured repositories",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
repos, err := rm.ListRepos(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range repos {
|
||||
status := "disabled"
|
||||
if r.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
fmt.Printf("%s %s [%s]\n", r.ID, r.URL, status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add <url>",
|
||||
Short: "Add a package repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
repo := snack.Repository{
|
||||
URL: args[0],
|
||||
Enabled: true,
|
||||
}
|
||||
return rm.AddRepo(cmd.Context(), repo)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a package repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
return rm.RemoveRepo(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "key",
|
||||
Short: "Manage GPG signing keys",
|
||||
}
|
||||
cmd.AddCommand(keyListCmd(), keyAddCmd(), keyRemoveCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func keyListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List trusted signing keys",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
keys, err := km.ListKeys(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
fmt.Println(k)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add <key>",
|
||||
Short: "Add a GPG signing key (URL, file path, or key ID)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
return km.AddKey(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <key-id>",
|
||||
Short: "Remove a GPG signing key",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
return km.RemoveKey(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "Manage package groups",
|
||||
}
|
||||
cmd.AddCommand(groupListCmd(), groupInfoCmd(), groupInstallCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func groupListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available package groups",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
groups, err := g.GroupList(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, grp := range groups {
|
||||
fmt.Println(grp)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupInfoCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "info <group>",
|
||||
Short: "Show packages in a group",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
pkgs, err := g.GroupInfo(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
fmt.Println(p.Name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupInstallCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "install <group>",
|
||||
Short: "Install all packages in a group",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
return g.GroupInstall(cmd.Context(), args[0], opts()...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,14 +122,15 @@ func TestGetManager(t *testing.T) {
|
||||
t.Error("expected non-empty manager name")
|
||||
}
|
||||
|
||||
// Explicit override
|
||||
flagMgr = "apt"
|
||||
m, err = getManager()
|
||||
// Explicit override - use the detected manager's name
|
||||
// since not all managers are available on all platforms
|
||||
flagMgr = m.Name()
|
||||
m2, err := getManager()
|
||||
if err != nil {
|
||||
t.Fatalf("getManager() with --manager=apt failed: %v", err)
|
||||
t.Fatalf("getManager() with --manager=%s failed: %v", flagMgr, err)
|
||||
}
|
||||
if m.Name() != "apt" {
|
||||
t.Errorf("expected Name()=apt, got %q", m.Name())
|
||||
if m2.Name() != flagMgr {
|
||||
t.Errorf("expected Name()=%s, got %q", flagMgr, m2.Name())
|
||||
}
|
||||
|
||||
// Unknown manager
|
||||
|
||||
20
detect/detect_darwin.go
Normal file
20
detect/detect_darwin.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build darwin
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/brew"
|
||||
)
|
||||
|
||||
// candidates returns manager factories in probe order for macOS.
|
||||
func candidates() []managerFactory {
|
||||
return []managerFactory{
|
||||
func() snack.Manager { return brew.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
func allManagers() []managerFactory {
|
||||
return candidates()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gogrlx/snack/apk"
|
||||
"github.com/gogrlx/snack/apt"
|
||||
"github.com/gogrlx/snack/aur"
|
||||
"github.com/gogrlx/snack/brew"
|
||||
"github.com/gogrlx/snack/dnf"
|
||||
"github.com/gogrlx/snack/flatpak"
|
||||
"github.com/gogrlx/snack/pacman"
|
||||
@@ -23,11 +24,12 @@ func candidates() []managerFactory {
|
||||
func() snack.Manager { return apk.New() },
|
||||
func() snack.Manager { return flatpak.New() },
|
||||
func() snack.Manager { return snap.New() },
|
||||
func() snack.Manager { return brew.New() },
|
||||
func() snack.Manager { return aur.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
// Includes supplemental managers like AUR that aren't primary candidates.
|
||||
func allManagers() []managerFactory {
|
||||
return append(candidates(), func() snack.Manager { return aur.New() })
|
||||
return candidates()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !freebsd && !openbsd
|
||||
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
|
||||
|
||||
package detect
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
|
||||
20
detect/detect_windows.go
Normal file
20
detect/detect_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
)
|
||||
|
||||
// candidates returns manager factories in probe order for Windows.
|
||||
func candidates() []managerFactory {
|
||||
return []managerFactory{
|
||||
func() snack.Manager { return winget.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
func allManagers() []managerFactory {
|
||||
return candidates()
|
||||
}
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
|
||||
// Compile-time interface assertions — DNF implements all optional interfaces.
|
||||
var (
|
||||
_ snack.Manager = (*DNF)(nil)
|
||||
_ snack.VersionQuerier = (*DNF)(nil)
|
||||
_ snack.Holder = (*DNF)(nil)
|
||||
_ snack.Cleaner = (*DNF)(nil)
|
||||
_ snack.FileOwner = (*DNF)(nil)
|
||||
_ snack.RepoManager = (*DNF)(nil)
|
||||
_ snack.KeyManager = (*DNF)(nil)
|
||||
_ snack.Grouper = (*DNF)(nil)
|
||||
_ snack.NameNormalizer = (*DNF)(nil)
|
||||
_ snack.DryRunner = (*DNF)(nil)
|
||||
_ snack.Manager = (*DNF)(nil)
|
||||
_ snack.VersionQuerier = (*DNF)(nil)
|
||||
_ snack.Holder = (*DNF)(nil)
|
||||
_ snack.Cleaner = (*DNF)(nil)
|
||||
_ snack.FileOwner = (*DNF)(nil)
|
||||
_ snack.RepoManager = (*DNF)(nil)
|
||||
_ snack.KeyManager = (*DNF)(nil)
|
||||
_ snack.Grouper = (*DNF)(nil)
|
||||
_ snack.NameNormalizer = (*DNF)(nil)
|
||||
_ snack.DryRunner = (*DNF)(nil)
|
||||
_ snack.PackageUpgrader = (*DNF)(nil)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@ func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a flatpak app ID.
|
||||
func (f *Flatpak) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a flatpak reference if present.
|
||||
func (f *Flatpak) ParseArch(name string) (string, string) {
|
||||
return parseRef(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Flatpak)(nil)
|
||||
var _ snack.PackageUpgrader = (*Flatpak)(nil)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Flatpak(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.Clean, "flatpak should support Clean")
|
||||
assert.True(t, caps.RepoManagement, "flatpak should support RepoManagement")
|
||||
assert.False(t, caps.VersionQuery)
|
||||
assert.True(t, caps.VersionQuery, "flatpak should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "flatpak should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -220,11 +220,8 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
|
||||
if len(toUpgrade) > 0 {
|
||||
for _, t := range toUpgrade {
|
||||
args := []string{"update", "-y", t.Name}
|
||||
cmd := exec.CommandContext(ctx, "flatpak", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w: %s", t.Name, err, stderr.String())
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ func removeRepo(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
@@ -397,8 +397,8 @@ func TestCapabilities(t *testing.T) {
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=false")
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
|
||||
29
flatpak/normalize.go
Normal file
29
flatpak/normalize.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package flatpak
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a flatpak application ID.
|
||||
// Flatpak references can include branch/arch suffixes like:
|
||||
// - org.gnome.Calculator/x86_64/stable
|
||||
// - org.gnome.Calculator//stable (default arch)
|
||||
//
|
||||
// This strips branch and arch to return just the app ID.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseRef(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseRef extracts the architecture from a flatpak reference if present.
|
||||
// Flatpak references can be in the form:
|
||||
// - app-id
|
||||
// - app-id/arch/branch
|
||||
// - app-id//branch (default arch)
|
||||
//
|
||||
// Returns the app-id and architecture (or empty string).
|
||||
func parseRef(name string) (string, string) {
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
if len(parts) >= 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
37
go.mod
37
go.mod
@@ -1,27 +1,27 @@
|
||||
module github.com/gogrlx/snack
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/go-git/go-git/v5 v5.17.0
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/go-git/go-git/v5 v5.17.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/lipgloss/v2 v2.0.0 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.1 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
@@ -33,7 +33,7 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
@@ -51,12 +51,13 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
@@ -72,15 +73,15 @@ require (
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
@@ -95,10 +96,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
|
||||
78
go.sum
78
go.sum
@@ -1,5 +1,5 @@
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
|
||||
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
@@ -9,28 +9,28 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg6
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
|
||||
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
|
||||
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 h1:wQs/I0JSEkcHzobvAgfzeJOKm9A8mkeDOkWQxAo0AZc=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -58,8 +58,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -87,8 +87,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz
|
||||
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
|
||||
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
|
||||
github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk=
|
||||
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -109,10 +109,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -126,8 +128,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
@@ -162,8 +164,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -175,15 +177,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -229,15 +231,15 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -249,11 +251,11 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
|
||||
53
install.sh
Executable file
53
install.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
# Install snack - a unified CLI for system package managers
|
||||
# Usage: curl -sSfL https://raw.githubusercontent.com/gogrlx/snack/main/install.sh | sh
|
||||
set -e
|
||||
|
||||
REPO="gogrlx/snack"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||
|
||||
# Detect OS and arch
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
armv*) ARCH="arm" ;;
|
||||
esac
|
||||
|
||||
# macOS universal binary
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
ARCH="universal"
|
||||
fi
|
||||
|
||||
echo "Detected: ${OS}/${ARCH}"
|
||||
|
||||
# Get latest release tag
|
||||
TAG="$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not determine latest version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing snack ${VERSION}..."
|
||||
|
||||
TARBALL="snack-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||
URL="https://github.com/${REPO}/releases/download/${TAG}/${TARBALL}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
curl -sSfL "$URL" -o "${TMP}/${TARBALL}"
|
||||
tar xzf "${TMP}/${TARBALL}" -C "$TMP"
|
||||
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
mv "${TMP}/snack" "${INSTALL_DIR}/snack"
|
||||
else
|
||||
echo "Installing to ${INSTALL_DIR} (requires sudo)..."
|
||||
sudo mv "${TMP}/snack" "${INSTALL_DIR}/snack"
|
||||
fi
|
||||
|
||||
chmod +x "${INSTALL_DIR}/snack"
|
||||
echo "snack ${VERSION} installed to ${INSTALL_DIR}/snack"
|
||||
65
pacman/buildargs_linux_test.go
Normal file
65
pacman/buildargs_linux_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base []string
|
||||
opts snack.Options
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with sudo",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Sudo: true},
|
||||
wantCmd: "sudo",
|
||||
wantArgs: []string{"pacman", "-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with root and noconfirm",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Root: "/mnt", AssumeYes: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{DryRun: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim", "--print"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, args := buildArgs(tt.base, tt.opts)
|
||||
if cmd != tt.wantCmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
|
||||
}
|
||||
if len(args) != len(tt.wantArgs) {
|
||||
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tt.wantArgs[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var (
|
||||
_ snack.Cleaner = (*Pacman)(nil)
|
||||
_ snack.FileOwner = (*Pacman)(nil)
|
||||
_ snack.Grouper = (*Pacman)(nil)
|
||||
_ snack.NameNormalizer = (*Pacman)(nil)
|
||||
_ snack.DryRunner = (*Pacman)(nil)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
|
||||
16
pacman/normalize.go
Normal file
16
pacman/normalize.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package pacman
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Pacman package names do not include architecture suffixes, so this
|
||||
// is essentially a pass-through. The package name is returned as-is.
|
||||
func normalizeName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a package name if present.
|
||||
// Pacman package names do not include architecture suffixes in the name itself
|
||||
// (the arch is separate metadata), so this returns the name unchanged with an
|
||||
// empty architecture string.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
@@ -83,6 +83,16 @@ func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Pacman) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Pacman) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
var _ snack.PackageUpgrader = (*Pacman)(nil)
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Pacman(t *testing.T) {
|
||||
assert.False(t, caps.Hold, "pacman should not support Hold")
|
||||
assert.False(t, caps.RepoManagement, "pacman should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "pacman should not support KeyManagement")
|
||||
assert.False(t, caps.NameNormalize, "pacman should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "pacman should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -72,62 +72,6 @@ Architecture : x86_64
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base []string
|
||||
opts snack.Options
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with sudo",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Sudo: true},
|
||||
wantCmd: "sudo",
|
||||
wantArgs: []string{"pacman", "-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with root and noconfirm",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Root: "/mnt", AssumeYes: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{DryRun: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim", "--print"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, args := buildArgs(tt.base, tt.opts)
|
||||
if cmd != tt.wantCmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
|
||||
}
|
||||
if len(args) != len(tt.wantArgs) {
|
||||
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tt.wantArgs[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
var _ snack.VersionQuerier = (*Pacman)(nil)
|
||||
@@ -151,8 +95,8 @@ func TestInterfaceNonCompliance(t *testing.T) {
|
||||
if _, ok := m.(snack.KeyManager); ok {
|
||||
t.Error("Pacman should not implement KeyManager")
|
||||
}
|
||||
if _, ok := m.(snack.NameNormalizer); ok {
|
||||
t.Error("Pacman should not implement NameNormalizer")
|
||||
if _, ok := m.(snack.NameNormalizer); !ok {
|
||||
t.Error("Pacman should implement NameNormalizer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +116,7 @@ func TestCapabilities(t *testing.T) {
|
||||
{"Hold", caps.Hold, false},
|
||||
{"RepoManagement", caps.RepoManagement, false},
|
||||
{"KeyManagement", caps.KeyManagement, false},
|
||||
{"NameNormalize", caps.NameNormalize, false},
|
||||
{"NameNormalize", caps.NameNormalize, true},
|
||||
{"PackageUpgrade", caps.PackageUpgrade, true},
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Pkg)(nil)
|
||||
_ snack.Cleaner = (*Pkg)(nil)
|
||||
_ snack.FileOwner = (*Pkg)(nil)
|
||||
_ snack.NameNormalizer = (*Pkg)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version from configured repositories.
|
||||
|
||||
17
pkg/normalize.go
Normal file
17
pkg/normalize.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package pkg
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// FreeBSD pkg package names use "name-version" format. This function
|
||||
// strips the version portion if present, returning just the name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := splitNameVersion(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// FreeBSD pkg package names do not embed architecture in the name itself
|
||||
// (the arch is separate metadata), so this returns the name unchanged with
|
||||
// an empty architecture string.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
10
pkg/pkg.go
10
pkg/pkg.go
@@ -83,6 +83,16 @@ func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Pkg) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Pkg) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Pkg)(nil)
|
||||
var _ snack.PackageUpgrader = (*Pkg)(nil)
|
||||
|
||||
@@ -659,8 +659,8 @@ func TestCapabilities(t *testing.T) {
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=false")
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Ports)(nil)
|
||||
_ snack.Cleaner = (*Ports)(nil)
|
||||
_ snack.FileOwner = (*Ports)(nil)
|
||||
_ snack.NameNormalizer = (*Ports)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version from configured repositories.
|
||||
@@ -29,13 +30,11 @@ func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
// OpenBSD has no native version comparison tool, so this uses a simple
|
||||
// lexicographic comparison of the version strings.
|
||||
func (p *Ports) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove removes packages no longer required as dependencies.
|
||||
// Autoremove removes packages that are no longer needed.
|
||||
func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
@@ -78,40 +78,6 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
// OpenBSD does not maintain a package cache like FreeBSD/apt.
|
||||
// Downloaded packages are removed after installation by default.
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("ports fileList: %w", err)
|
||||
}
|
||||
return parseFileListOutput(out), nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("ports owner: %w", err)
|
||||
}
|
||||
return parseOwnerOutput(out), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
|
||||
16
ports/normalize.go
Normal file
16
ports/normalize.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ports
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// OpenBSD packages use "name-version" format. This strips the version
|
||||
// portion if present, returning just the name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := splitNameVersion(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// OpenBSD package names do not embed architecture in the name itself
|
||||
// (the arch is separate), so this returns the name unchanged.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
@@ -81,6 +81,16 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Ports) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Ports) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Ports)(nil)
|
||||
var _ snack.PackageUpgrader = (*Ports)(nil)
|
||||
|
||||
@@ -183,3 +183,55 @@ func version(ctx context.Context, pkg string) (string, error) {
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
// pkg_delete -a removes all packages not required by other packages
|
||||
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
// OpenBSD doesn't cache packages by default, no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("ports fileList: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Information for") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Files:") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "/") {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("ports owner: %w", err)
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
// Output is the package name
|
||||
return strings.Split(out, "\n")[0], nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -4,182 +4,25 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" {
|
||||
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name : bash
|
||||
Version : 5.1.8
|
||||
Release : 6.el9
|
||||
Architecture: x86_64
|
||||
Install Date: Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Group : System Environment/Shells
|
||||
Size : 7896043
|
||||
License : GPLv3+
|
||||
Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123
|
||||
Source RPM : bash-5.1.8-6.el9.src.rpm
|
||||
Build Date : Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Build Host : builder.example.com
|
||||
Packager : CentOS Buildsys <bugs@centos.org>
|
||||
Vendor : CentOS
|
||||
URL : https://www.gnu.org/software/bash
|
||||
Summary : The GNU Bourne Again shell
|
||||
Description :
|
||||
The GNU Bourne Again shell (Bash) is a shell or command language
|
||||
interpreter that is compatible with the Bourne shell (sh).
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p == nil {
|
||||
t.Fatal("expected package, got nil")
|
||||
}
|
||||
if p.Name != "bash" {
|
||||
t.Errorf("Name = %q, want bash", p.Name)
|
||||
}
|
||||
if p.Version != "5.1.8-6.el9" {
|
||||
t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version)
|
||||
}
|
||||
if p.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", p.Arch)
|
||||
}
|
||||
if p.Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"nginx", "nginx"},
|
||||
{"nginx.x86_64", "nginx"},
|
||||
{"curl.aarch64", "curl"},
|
||||
{"bash.noarch", "bash"},
|
||||
{"python3", "python3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, wantName, wantArch string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx", "x86_64"},
|
||||
{"bash", "bash", ""},
|
||||
{"glibc.i686", "glibc", "i686"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, arch := parseArchSuffix(tt.input)
|
||||
if name != tt.wantName || arch != tt.wantArch {
|
||||
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edge case tests ---
|
||||
|
||||
func TestParseListEmpty(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListSinglePackage(t *testing.T) {
|
||||
input := "curl\t7.76.1-23.el9\tA utility\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" {
|
||||
t.Errorf("Name = %q, want curl", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListNoDescription(t *testing.T) {
|
||||
input := "bash\t5.1.8-6.el9\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("Description = %q, want empty", pkgs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListMalformedLines(t *testing.T) {
|
||||
input := "bash\t5.1.8-6.el9\tShell\nno-tab-here\ncurl\t7.76.1\tHTTP tool\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEmpty(t *testing.T) {
|
||||
p := parseInfo("")
|
||||
if p != nil {
|
||||
t.Errorf("expected nil from empty input, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoNoName(t *testing.T) {
|
||||
input := `Version : 1.0
|
||||
Architecture: x86_64
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p != nil {
|
||||
t.Errorf("expected nil when no Name field, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoArchField(t *testing.T) {
|
||||
// Test both "Architecture" and "Arch" key forms
|
||||
input := `Name : test
|
||||
Version : 1.0
|
||||
Release : 1.el9
|
||||
Arch : aarch64
|
||||
Summary : Test package
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if p.Arch != "aarch64" {
|
||||
t.Errorf("Arch = %q, want aarch64", p.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNameEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"curl.noarch", "curl"},
|
||||
{"kernel.aarch64", "kernel"},
|
||||
{"bash.i686", "bash"},
|
||||
{"glibc.i386", "glibc"},
|
||||
{"libfoo.armv7hl", "libfoo"},
|
||||
{"module.ppc64le", "module"},
|
||||
{"app.s390x", "app"},
|
||||
{"source.src", "source"},
|
||||
{"nodot", "nodot"},
|
||||
{"", ""},
|
||||
{"pkg.unknown.ext", "pkg.unknown.ext"},
|
||||
{"name.with.dots.x86_64", "name.with.dots"},
|
||||
{"python3.11", "python3.11"},
|
||||
{"glibc.s390x", "glibc"},
|
||||
{"kernel.src", "kernel"},
|
||||
{".x86_64", ""},
|
||||
{"pkg.ppc64le", "pkg"},
|
||||
{"pkg.armv7hl", "pkg"},
|
||||
{"pkg.i386", "pkg"},
|
||||
{"pkg.unknown", "pkg.unknown"},
|
||||
{"multi.dot.x86_64", "multi.dot"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
@@ -191,30 +34,202 @@ func TestNormalizeNameEdgeCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchSuffixEdgeCases(t *testing.T) {
|
||||
func TestParseArchSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, wantName, wantArch string
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"pkg.i386", "pkg", "i386"},
|
||||
{"pkg.ppc64le", "pkg", "ppc64le"},
|
||||
{"pkg.s390x", "pkg", "s390x"},
|
||||
{"pkg.armv7hl", "pkg", "armv7hl"},
|
||||
{"pkg.src", "pkg", "src"},
|
||||
{"pkg.aarch64", "pkg", "aarch64"},
|
||||
{"pkg.noarch", "pkg", "noarch"},
|
||||
{"pkg.unknown", "pkg.unknown", ""},
|
||||
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
|
||||
{".noarch", "", "noarch"},
|
||||
{"pkg.x86_64.extra", "pkg.x86_64.extra", ""},
|
||||
{"x86_64", "nginx.x86_64", "nginx", "x86_64"},
|
||||
{"noarch", "bash.noarch", "bash", "noarch"},
|
||||
{"aarch64", "kernel.aarch64", "kernel", "aarch64"},
|
||||
{"i686", "glibc.i686", "glibc", "i686"},
|
||||
{"i386", "compat.i386", "compat", "i386"},
|
||||
{"armv7hl", "lib.armv7hl", "lib", "armv7hl"},
|
||||
{"ppc64le", "app.ppc64le", "app", "ppc64le"},
|
||||
{"s390x", "z.s390x", "z", "s390x"},
|
||||
{"src", "pkg.src", "pkg", "src"},
|
||||
{"no dot", "curl", "curl", ""},
|
||||
{"unknown arch", "pkg.foobar", "pkg.foobar", ""},
|
||||
{"empty", "", "", ""},
|
||||
{"multiple dots", "a.b.x86_64", "a.b", "x86_64"},
|
||||
{"dot but not arch", "libfoo.so", "libfoo.so", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
name, arch := parseArchSuffix(tt.input)
|
||||
if name != tt.wantName || arch != tt.wantArch {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotArch := parseArchSuffix(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, name, arch, tt.wantName, tt.wantArch)
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.2.15-3.fc38\tThe GNU Bourne Again shell\n" +
|
||||
"curl\t8.0.1-1.fc38\tA utility for getting files from remote servers\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2.15-3.fc38" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace only", func(t *testing.T) {
|
||||
pkgs := parseList(" \n\n \n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single entry no description", func(t *testing.T) {
|
||||
pkgs := parseList("vim\t9.0.1\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.1" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field line skipped", func(t *testing.T) {
|
||||
pkgs := parseList("justname\n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (need >=2 tab fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("description with tabs", func(t *testing.T) {
|
||||
pkgs := parseList("pkg\t1.0\tA description\twith tabs\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
// SplitN with 3 means the third part includes everything after the second tab
|
||||
if pkgs[0].Description != "A description\twith tabs" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name : curl
|
||||
Version : 8.0.1
|
||||
Release : 1.fc38
|
||||
Architecture: x86_64
|
||||
Summary : A utility for getting files from remote servers
|
||||
`
|
||||
pkg := parseInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "curl" {
|
||||
t.Errorf("expected name 'curl', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "8.0.1-1.fc38" {
|
||||
t.Errorf("expected version '8.0.1-1.fc38', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Arch != "x86_64" {
|
||||
t.Errorf("expected arch 'x86_64', got %q", pkg.Arch)
|
||||
}
|
||||
if pkg.Description != "A utility for getting files from remote servers" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkg := parseInfo("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : bash\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Name != "bash" {
|
||||
t.Errorf("expected bash, got %q", pkg.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no name returns nil", func(t *testing.T) {
|
||||
pkg := parseInfo("Version : 1.0\nArch : x86_64\n")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Name field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version without release", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nVersion : 2.5\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Version != "2.5" {
|
||||
t.Errorf("expected version '2.5', got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("release without version", func(t *testing.T) {
|
||||
// Release only appends if version is non-empty
|
||||
pkg := parseInfo("Name : test\nRelease : 3.el9\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Version != "" {
|
||||
t.Errorf("expected empty version (release alone shouldn't set it), got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("arch key variant", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nArch : aarch64\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Arch != "aarch64" {
|
||||
t.Errorf("expected aarch64, got %q", pkg.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no colon lines ignored", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nrandom line\nSummary : A tool\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Description != "A tool" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value with colons", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : myapp\nSummary : A tool: does things: well\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Description != "A tool: does things: well" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Snap)(nil)
|
||||
_ snack.Cleaner = (*Snap)(nil)
|
||||
_ snack.NameNormalizer = (*Snap)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest stable version of a snap.
|
||||
@@ -32,13 +33,12 @@ func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove is a no-op for snap. Snaps are self-contained and do not
|
||||
// have orphan dependencies.
|
||||
func (s *Snap) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
return autoremove(ctx, opts...)
|
||||
// Autoremove is a no-op for snap (snap doesn't have orphan packages).
|
||||
func (s *Snap) Autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean removes old disabled snap revisions to free disk space.
|
||||
// Clean removes old snap revisions to free up space.
|
||||
func (s *Snap) Clean(ctx context.Context) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
15
snap/normalize.go
Normal file
15
snap/normalize.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package snap
|
||||
|
||||
// normalizeName returns the canonical form of a snap name.
|
||||
// Snap package names are simple identifiers without architecture or version
|
||||
// suffixes, so this is essentially a pass-through.
|
||||
func normalizeName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a snap name if present.
|
||||
// Snap package names do not include architecture suffixes,
|
||||
// so this returns the name unchanged with an empty string.
|
||||
func parseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
10
snap/snap.go
10
snap/snap.go
@@ -81,6 +81,16 @@ func (s *Snap) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a snap name.
|
||||
func (s *Snap) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a snap name if present.
|
||||
func (s *Snap) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Snap)(nil)
|
||||
var _ snack.PackageUpgrader = (*Snap)(nil)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Snap(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "snap should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.True(t, caps.Clean, "snap should support Clean")
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.RepoManagement)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "snap should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -232,43 +232,21 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
// autoremove is a no-op for snap. Snaps are self-contained and do not
|
||||
// have orphan dependencies.
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clean removes old disabled snap revisions to free disk space.
|
||||
// It runs `snap list --all` to find disabled revisions, then removes
|
||||
// each one with `snap remove --revision=<rev> <name>`.
|
||||
func clean(ctx context.Context) error {
|
||||
// Remove disabled snap revisions to free up space
|
||||
out, err := run(ctx, []string{"list", "--all"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("snap clean: %w", err)
|
||||
return err
|
||||
}
|
||||
// Parse output for disabled revisions
|
||||
// Header: Name Version Rev Tracking Publisher Notes
|
||||
// Disabled snaps have "disabled" in the Notes column
|
||||
lines := strings.Split(out, "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, "disabled") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
name := fields[0]
|
||||
rev := fields[2]
|
||||
if _, err := run(ctx, []string{"remove", "--revision=" + rev, name}); err != nil {
|
||||
return fmt.Errorf("snap clean %s rev %s: %w", name, rev, err)
|
||||
if len(fields) >= 3 {
|
||||
name := fields[0]
|
||||
rev := fields[2]
|
||||
_, _ = run(ctx, []string{"remove", name, "--revision=" + rev})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -293,14 +271,9 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
if len(toUpgrade) > 0 {
|
||||
for _, t := range toUpgrade {
|
||||
cmd := exec.CommandContext(ctx, "snap", "refresh", t.Name)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w: %s", t.Name, err, stderr.String())
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
if _, err := run(ctx, []string{"refresh", t.Name}); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
|
||||
@@ -66,10 +66,6 @@ func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
@@ -441,8 +441,8 @@ func TestCapabilities(t *testing.T) {
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=false")
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
|
||||
49
winget/capabilities.go
Normal file
49
winget/capabilities.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Winget)(nil)
|
||||
_ snack.RepoManager = (*Winget)(nil)
|
||||
_ snack.NameNormalizer = (*Winget)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version of a package.
|
||||
func (w *Winget) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (w *Winget) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (w *Winget) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (w *Winget) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// ListRepos returns configured winget sources.
|
||||
func (w *Winget) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
return sourceList(ctx)
|
||||
}
|
||||
|
||||
// AddRepo adds a new winget source.
|
||||
func (w *Winget) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||
return sourceAdd(ctx, repo)
|
||||
}
|
||||
|
||||
// RemoveRepo removes a configured winget source.
|
||||
func (w *Winget) RemoveRepo(ctx context.Context, id string) error {
|
||||
return sourceRemove(ctx, id)
|
||||
}
|
||||
19
winget/normalize.go
Normal file
19
winget/normalize.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package winget
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a winget package ID.
|
||||
// Winget IDs use dot-separated Publisher.Package format (e.g.
|
||||
// "Microsoft.VisualStudioCode"). This trims whitespace but otherwise
|
||||
// preserves the ID as-is since winget IDs are case-insensitive but
|
||||
// conventionally PascalCase.
|
||||
func normalizeName(name string) string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a winget package name if present.
|
||||
// Winget IDs do not include architecture suffixes, so this returns the
|
||||
// name unchanged with an empty string.
|
||||
func parseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
333
winget/parse.go
Normal file
333
winget/parse.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseTable parses winget tabular output (list, search, upgrade).
|
||||
//
|
||||
// Winget uses fixed-width columns whose positions are determined by a
|
||||
// header row with dashes (e.g. "---- ------ -------").
|
||||
// The header names vary by locale, so we detect columns positionally.
|
||||
//
|
||||
// Typical `winget list` output:
|
||||
//
|
||||
// Name Id Version Available Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.8.0 17.9.0 winget
|
||||
//
|
||||
// Typical `winget search` output:
|
||||
//
|
||||
// Name Id Version Match Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.9.0 winget
|
||||
//
|
||||
// When installed is true, we mark all parsed packages as Installed.
|
||||
func parseTable(output string, installed bool) []snack.Package {
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
// Find the separator line (all dashes/spaces) to determine column positions.
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if isSeparatorLine(trimmed) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the header line (just above separator) to determine column starts.
|
||||
header := lines[sepIdx-1]
|
||||
cols := detectColumns(header)
|
||||
if len(cols) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pkgs []snack.Package
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
// Skip footer lines like "X upgrades available."
|
||||
if isFooterLine(line) {
|
||||
continue
|
||||
}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Installed: installed,
|
||||
}
|
||||
// Column order: Name, Id, Version, [Available], [Source]
|
||||
// For search: Name, Id, Version, [Match], [Source]
|
||||
if len(fields) >= 3 {
|
||||
pkg.Version = fields[2]
|
||||
}
|
||||
// Use the ID as the package name for consistency (winget uses
|
||||
// Publisher.Package IDs as the canonical identifier).
|
||||
if len(fields) >= 2 && fields[1] != "" {
|
||||
pkg.Description = fields[0] // keep display name as description
|
||||
pkg.Name = fields[1]
|
||||
}
|
||||
// If there's a Source column (typically the last), use it.
|
||||
if len(fields) >= 5 && fields[len(fields)-1] != "" {
|
||||
pkg.Repository = fields[len(fields)-1]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// isSeparatorLine returns true if the line is a column separator
|
||||
// (composed entirely of dashes and spaces, with at least some dashes).
|
||||
func isSeparatorLine(line string) bool {
|
||||
hasDash := false
|
||||
for _, c := range line {
|
||||
switch c {
|
||||
case '-':
|
||||
hasDash = true
|
||||
case ' ', '\t':
|
||||
// allowed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDash
|
||||
}
|
||||
|
||||
// detectColumns returns the starting index of each column based on
|
||||
// the header line. Columns are separated by 2+ spaces.
|
||||
func detectColumns(header string) []int {
|
||||
var cols []int
|
||||
inWord := false
|
||||
for i, c := range header {
|
||||
if c != ' ' && c != '\t' {
|
||||
if !inWord {
|
||||
cols = append(cols, i)
|
||||
inWord = true
|
||||
}
|
||||
} else {
|
||||
// Need at least 2 spaces to end a column
|
||||
if inWord && i+1 < len(header) && (header[i+1] == ' ' || header[i+1] == '\t') {
|
||||
inWord = false
|
||||
} else if inWord && i+1 >= len(header) {
|
||||
inWord = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// extractFields splits a data line according to detected column positions.
|
||||
func extractFields(line string, cols []int) []string {
|
||||
fields := make([]string, len(cols))
|
||||
for i, start := range cols {
|
||||
if start >= len(line) {
|
||||
break
|
||||
}
|
||||
end := len(line)
|
||||
if i+1 < len(cols) {
|
||||
end = cols[i+1]
|
||||
if end > len(line) {
|
||||
end = len(line)
|
||||
}
|
||||
}
|
||||
fields[i] = strings.TrimSpace(line[start:end])
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// isFooterLine returns true for winget output footer lines.
|
||||
func isFooterLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "upgrades available") ||
|
||||
strings.Contains(lower, "package(s)") ||
|
||||
strings.Contains(lower, "installed package") ||
|
||||
strings.HasPrefix(lower, "the following") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseShow parses `winget show` key-value output into a Package.
|
||||
//
|
||||
// Typical output:
|
||||
//
|
||||
// Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
// Version: 1.85.0
|
||||
// Publisher: Microsoft Corporation
|
||||
// Description: Code editing. Redefined.
|
||||
// ...
|
||||
func parseShow(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Parse "Found <Name> [<Id>]" header line.
|
||||
if strings.HasPrefix(line, "Found ") {
|
||||
if idx := strings.LastIndex(line, "["); idx > 0 {
|
||||
endIdx := strings.LastIndex(line, "]")
|
||||
if endIdx > idx {
|
||||
pkg.Name = strings.TrimSpace(line[idx+1 : endIdx])
|
||||
pkg.Description = strings.TrimSpace(line[6:idx])
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
switch strings.ToLower(key) {
|
||||
case "version":
|
||||
pkg.Version = val
|
||||
case "description":
|
||||
if pkg.Description == "" {
|
||||
pkg.Description = val
|
||||
}
|
||||
case "publisher":
|
||||
// informational only
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseSourceList parses `winget source list` output into Repositories.
|
||||
//
|
||||
// Output format:
|
||||
//
|
||||
// Name Argument
|
||||
// ----------------------------------------------
|
||||
// winget https://cdn.winget.microsoft.com/cache
|
||||
// msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
func parseSourceList(output string) []snack.Repository {
|
||||
lines := strings.Split(output, "\n")
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
if isSeparatorLine(strings.TrimSpace(line)) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
var repos []snack.Repository
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, snack.Repository{
|
||||
ID: fields[0],
|
||||
Name: fields[0],
|
||||
URL: fields[1],
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
return repos
|
||||
}
|
||||
|
||||
// stripVT removes ANSI/VT100 escape sequences from a string.
|
||||
func stripVT(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||
// Skip CSI sequence: ESC [ ... final byte
|
||||
j := i + 2
|
||||
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++ // skip final byte
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isProgressLine returns true for lines that are only progress indicators.
|
||||
func isProgressLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
// Lines containing block characters and/or percentage
|
||||
hasBlock := strings.ContainsAny(trimmed, "█▓░")
|
||||
hasPercent := strings.Contains(trimmed, "%")
|
||||
if hasBlock || (hasPercent && len(trimmed) < 40) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// semverCmp does a basic semver-ish comparison.
|
||||
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
func semverCmp(a, b string) int {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
|
||||
maxLen := len(partsA)
|
||||
if len(partsB) > maxLen {
|
||||
maxLen = len(partsB)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var numA, numB int
|
||||
if i < len(partsA) {
|
||||
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
|
||||
}
|
||||
if i < len(partsB) {
|
||||
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
|
||||
}
|
||||
if numA < numB {
|
||||
return -1
|
||||
}
|
||||
if numA > numB {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stripNonNumeric keeps only leading digits from a string.
|
||||
func stripNonNumeric(s string) string {
|
||||
for i, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
103
winget/winget.go
Normal file
103
winget/winget.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package winget provides Go bindings for the Windows Package Manager (winget).
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Winget wraps the winget CLI.
|
||||
type Winget struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Winget manager.
|
||||
func New() *Winget {
|
||||
return &Winget{}
|
||||
}
|
||||
|
||||
// Name returns "winget".
|
||||
func (w *Winget) Name() string { return "winget" }
|
||||
|
||||
// Available reports whether winget is present on the system.
|
||||
func (w *Winget) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (w *Winget) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (w *Winget) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including configuration data.
|
||||
func (w *Winget) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (w *Winget) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the winget source index.
|
||||
func (w *Winget) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (w *Winget) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the winget repository for packages matching the query.
|
||||
func (w *Winget) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (w *Winget) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (w *Winget) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (w *Winget) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a winget package ID.
|
||||
func (w *Winget) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (w *Winget) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
|
||||
// UpgradePackages upgrades specific installed packages.
|
||||
func (w *Winget) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
140
winget/winget_integration_test.go
Normal file
140
winget/winget_integration_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build integration
|
||||
|
||||
package winget_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntegration_Winget(t *testing.T) {
|
||||
var mgr snack.Manager = winget.New()
|
||||
if !mgr.Available() {
|
||||
t.Skip("winget not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(t, "winget", mgr.Name())
|
||||
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "winget should support VersionQuery")
|
||||
assert.True(t, caps.RepoManagement, "winget should support RepoManagement")
|
||||
assert.True(t, caps.NameNormalize, "winget should support NameNormalize")
|
||||
assert.True(t, caps.PackageUpgrade, "winget should support PackageUpgrade")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.DryRun)
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Search_NoResults", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Info", func(t *testing.T) {
|
||||
pkg, err := mgr.Info(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pkg)
|
||||
assert.Equal(t, "Microsoft.PowerToys", pkg.Name)
|
||||
assert.NotEmpty(t, pkg.Version)
|
||||
})
|
||||
|
||||
t.Run("Info_NotFound", func(t *testing.T) {
|
||||
_, err := mgr.Info(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
pkgs, err := mgr.List(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("installed packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
// --- VersionQuerier ---
|
||||
t.Run("VersionQuerier", func(t *testing.T) {
|
||||
vq, ok := mgr.(snack.VersionQuerier)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("LatestVersion", func(t *testing.T) {
|
||||
ver, err := vq.LatestVersion(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ver)
|
||||
t.Logf("PowerToys latest: %s", ver)
|
||||
})
|
||||
|
||||
t.Run("LatestVersion_NotFound", func(t *testing.T) {
|
||||
_, err := vq.LatestVersion(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("ListUpgrades", func(t *testing.T) {
|
||||
pkgs, err := vq.ListUpgrades(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("upgradable packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
t.Run("VersionCmp", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
v1, v2 string
|
||||
want int
|
||||
}{
|
||||
{"1.0", "2.0", -1},
|
||||
{"2.0", "1.0", 1},
|
||||
{"1.0", "1.0", 0},
|
||||
{"1.0.1", "1.0.0", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2)
|
||||
require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- RepoManager ---
|
||||
t.Run("RepoManager", func(t *testing.T) {
|
||||
rm, ok := mgr.(snack.RepoManager)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("ListRepos", func(t *testing.T) {
|
||||
repos, err := rm.ListRepos(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, repos, "should have at least winget and msstore sources")
|
||||
t.Logf("sources: %d", len(repos))
|
||||
for _, r := range repos {
|
||||
t.Logf(" %s -> %s", r.Name, r.URL)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- NameNormalizer ---
|
||||
t.Run("NameNormalizer", func(t *testing.T) {
|
||||
nn, ok := mgr.(snack.NameNormalizer)
|
||||
require.True(t, ok)
|
||||
|
||||
name := nn.NormalizeName(" Microsoft.VisualStudioCode ")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", name)
|
||||
|
||||
n, arch := nn.ParseArch("Microsoft.VisualStudioCode")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", n)
|
||||
assert.Empty(t, arch)
|
||||
})
|
||||
}
|
||||
83
winget/winget_other.go
Normal file
83
winget/winget_other.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build !windows
|
||||
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
|
||||
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceList(_ context.Context) ([]snack.Repository, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceAdd(_ context.Context, _ snack.Repository) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceRemove(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
449
winget/winget_test.go
Normal file
449
winget/winget_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseTableList(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Git Git.Git 2.43.0 winget
|
||||
Google Chrome Google.Chrome 120.0.6099.130 winget
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected ID as name, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "Visual Studio Code" {
|
||||
t.Errorf("expected display name as description, got %q", pkgs[0].Description)
|
||||
}
|
||||
if pkgs[0].Version != "1.85.0" {
|
||||
t.Errorf("expected version '1.85.0', got %q", pkgs[0].Version)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
if pkgs[0].Repository != "winget" {
|
||||
t.Errorf("expected repository 'winget', got %q", pkgs[0].Repository)
|
||||
}
|
||||
if pkgs[1].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableSearch(t *testing.T) {
|
||||
input := `Name Id Version Match Source
|
||||
-------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.86.0 Moniker: vscode winget
|
||||
VSCodium VSCodium.VSCodium 1.85.2 winget
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Installed {
|
||||
t.Error("expected Installed=false for search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableEmpty(t *testing.T) {
|
||||
input := `Name Id Version Source
|
||||
------------------------------
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableNoSeparator(t *testing.T) {
|
||||
pkgs := parseTable("No installed package found matching input criteria.", true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableWithFooter(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
--------------------------------------------------------------
|
||||
Git Git.Git 2.43.0 2.44.0 winget
|
||||
3 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableUpgrade(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Node.js OpenJS.NodeJS 20.10.0 21.5.0 winget
|
||||
2 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[1].Name != "OpenJS.NodeJS" {
|
||||
t.Errorf("expected 'OpenJS.NodeJS', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShow(t *testing.T) {
|
||||
input := `Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
Version: 1.86.0
|
||||
Publisher: Microsoft Corporation
|
||||
Publisher URL: https://code.visualstudio.com
|
||||
Author: Microsoft Corporation
|
||||
Moniker: vscode
|
||||
Description: Code editing. Redefined.
|
||||
License: MIT
|
||||
Installer Type: inno
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "1.86.0" {
|
||||
t.Errorf("expected version '1.86.0', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Description != "Visual Studio Code" {
|
||||
t.Errorf("expected 'Visual Studio Code', got %q", pkg.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNotFound(t *testing.T) {
|
||||
pkg := parseShow("No package found matching input criteria.")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for not-found output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowEmpty(t *testing.T) {
|
||||
pkg := parseShow("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNoID(t *testing.T) {
|
||||
input := `Version: 1.0.0
|
||||
Publisher: Someone
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Found header with ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceList(t *testing.T) {
|
||||
input := `Name Argument
|
||||
--------------------------------------------------------------
|
||||
winget https://cdn.winget.microsoft.com/cache
|
||||
msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
`
|
||||
repos := parseSourceList(input)
|
||||
if len(repos) != 2 {
|
||||
t.Fatalf("expected 2 repos, got %d", len(repos))
|
||||
}
|
||||
if repos[0].Name != "winget" {
|
||||
t.Errorf("expected 'winget', got %q", repos[0].Name)
|
||||
}
|
||||
if repos[0].URL != "https://cdn.winget.microsoft.com/cache" {
|
||||
t.Errorf("unexpected URL: %q", repos[0].URL)
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
if repos[1].Name != "msstore" {
|
||||
t.Errorf("expected 'msstore', got %q", repos[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceListEmpty(t *testing.T) {
|
||||
repos := parseSourceList("")
|
||||
if len(repos) != 0 {
|
||||
t.Fatalf("expected 0 repos, got %d", len(repos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeparatorLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"---", true},
|
||||
{"--------------------------------------------------------------", true},
|
||||
{"--- --- ---", true},
|
||||
{"Name Id Version", false},
|
||||
{"", false},
|
||||
{" ", false},
|
||||
{"--a--", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isSeparatorLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSeparatorLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectColumns(t *testing.T) {
|
||||
header := "Name Id Version Available Source"
|
||||
cols := detectColumns(header)
|
||||
if len(cols) != 5 {
|
||||
t.Fatalf("expected 5 columns, got %d: %v", len(cols), cols)
|
||||
}
|
||||
if cols[0] != 0 {
|
||||
t.Errorf("expected first column at 0, got %d", cols[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFields(t *testing.T) {
|
||||
line := "Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget"
|
||||
cols := []int{0, 34, 67, 82, 93}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) != 5 {
|
||||
t.Fatalf("expected 5 fields, got %d", len(fields))
|
||||
}
|
||||
if fields[0] != "Visual Studio Code" {
|
||||
t.Errorf("field[0] = %q, want 'Visual Studio Code'", fields[0])
|
||||
}
|
||||
if fields[1] != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("field[1] = %q, want 'Microsoft.VisualStudioCode'", fields[1])
|
||||
}
|
||||
if fields[2] != "1.85.0" {
|
||||
t.Errorf("field[2] = %q, want '1.85.0'", fields[2])
|
||||
}
|
||||
if fields[3] != "1.86.0" {
|
||||
t.Errorf("field[3] = %q, want '1.86.0'", fields[3])
|
||||
}
|
||||
if fields[4] != "winget" {
|
||||
t.Errorf("field[4] = %q, want 'winget'", fields[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldsShortLine(t *testing.T) {
|
||||
line := "Git"
|
||||
cols := []int{0, 34, 67}
|
||||
fields := extractFields(line, cols)
|
||||
if fields[0] != "Git" {
|
||||
t.Errorf("field[0] = %q, want 'Git'", fields[0])
|
||||
}
|
||||
if fields[1] != "" {
|
||||
t.Errorf("field[1] = %q, want ''", fields[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFooterLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"3 upgrades available.", true},
|
||||
{"1 package(s) found.", true},
|
||||
{"No installed package found.", true},
|
||||
{"The following packages have upgrades:", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isFooterLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isFooterLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"equal", "1.0.0", "1.0.0", 0},
|
||||
{"less major", "1.0.0", "2.0.0", -1},
|
||||
{"greater major", "2.0.0", "1.0.0", 1},
|
||||
{"less patch", "1.2.3", "1.2.4", -1},
|
||||
{"multi-digit minor", "1.10.0", "1.9.0", 1},
|
||||
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||
{"real versions", "1.85.0", "1.86.0", -1},
|
||||
{"single component", "5", "3", 1},
|
||||
{"empty vs empty", "", "", 0},
|
||||
{"empty vs version", "", "1.0", -1},
|
||||
{"version vs empty", "1.0", "", 1},
|
||||
{"four components", "120.0.6099.130", "120.0.6099.131", -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNonNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"123", "123"},
|
||||
{"123abc", "123"},
|
||||
{"abc", ""},
|
||||
{"0beta", "0"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripNonNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.VersionQuerier = (*Winget)(nil)
|
||||
var _ snack.RepoManager = (*Winget)(nil)
|
||||
var _ snack.NameNormalizer = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=true")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.Clean {
|
||||
t.Error("expected Clean=false")
|
||||
}
|
||||
if caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=false")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
w := New()
|
||||
if w.Name() != "winget" {
|
||||
t.Errorf("Name() = %q, want %q", w.Name(), "winget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Microsoft.VisualStudioCode", "Microsoft.VisualStudioCode"},
|
||||
{" Git.Git ", "Git.Git"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
name, arch := parseArch("Microsoft.VisualStudioCode")
|
||||
if name != "Microsoft.VisualStudioCode" || arch != "" {
|
||||
t.Errorf("parseArch returned (%q, %q), want (%q, %q)",
|
||||
name, arch, "Microsoft.VisualStudioCode", "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripVT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no escapes", "hello", "hello"},
|
||||
{"simple CSI", "\x1b[2Khello", "hello"},
|
||||
{"color code", "\x1b[32mgreen\x1b[0m", "green"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripVT(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripVT(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProgressLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"████████ 50%", true},
|
||||
{"100%", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", false},
|
||||
{"Installing...", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isProgressLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isProgressLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
360
winget/winget_windows.go
Normal file
360
winget/winget_windows.go
Normal file
@@ -0,0 +1,360 @@
|
||||
//go:build windows
|
||||
|
||||
package winget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// commonArgs returns flags used by all winget commands for non-interactive operation.
|
||||
func commonArgs() []string {
|
||||
return []string{
|
||||
"--accept-source-agreements",
|
||||
"--disable-interactivity",
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "winget", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
out := stdout.String()
|
||||
// winget writes progress and VT sequences to stdout; strip them.
|
||||
out = stripProgress(out)
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "Access is denied") ||
|
||||
strings.Contains(out, "administrator") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
combined := se + out
|
||||
if strings.Contains(combined, "No package found") ||
|
||||
strings.Contains(combined, "No installed package found") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrNotFound)
|
||||
}
|
||||
errMsg := strings.TrimSpace(se)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(out)
|
||||
}
|
||||
return "", fmt.Errorf("winget: %s: %w", errMsg, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripProgress removes VT100 escape sequences and progress lines from output.
|
||||
func stripProgress(s string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(s, "\n")
|
||||
for _, line := range lines {
|
||||
clean := stripVT(line)
|
||||
clean = strings.TrimRight(clean, "\r")
|
||||
// Skip pure progress lines (e.g. "██████████████ 50%")
|
||||
if isProgressLine(clean) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(clean)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toInstall []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.Reinstall || t.Version != "" || o.DryRun {
|
||||
toInstall = append(toInstall, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toInstall = append(toInstall, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toInstall {
|
||||
args := []string{"install", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if t.FromRepo != "" {
|
||||
args = append(args, "--source", t.FromRepo)
|
||||
} else if o.FromRepo != "" {
|
||||
args = append(args, "--source", o.FromRepo)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget install %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var installed []snack.Package
|
||||
for _, t := range toInstall {
|
||||
v, _ := version(ctx, t.Name)
|
||||
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toRemove []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toRemove = append(toRemove, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toRemove = append(toRemove, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toRemove {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.RemoveResult{}, fmt.Errorf("winget uninstall %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var removed []snack.Package
|
||||
for _, t := range toRemove {
|
||||
removed = append(removed, snack.Package{Name: t.Name})
|
||||
}
|
||||
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
for _, t := range pkgs {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent", "--purge"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return fmt.Errorf("winget purge %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
args := []string{"upgrade", "--all", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"source", "update"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget list: %w", err)
|
||||
}
|
||||
return parseTable(out, true), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
args := []string{"search", query}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("winget search: %w", err)
|
||||
}
|
||||
return parseTable(out, false), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("winget info: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
// Check if installed
|
||||
ok, _ := isInstalled(ctx, pkg)
|
||||
p.Installed = ok
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// "No installed package found" is returned as an error by run()
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("winget isInstalled: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
return len(pkgs) > 0, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("winget version: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("winget latestVersion: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"upgrade"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// No upgrades available may exit non-zero on some versions
|
||||
return nil, nil
|
||||
}
|
||||
return parseTable(out, false), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range upgrades {
|
||||
if strings.EqualFold(u.Name, pkg) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
args := []string{"upgrade", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget upgrade %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
for _, t := range toUpgrade {
|
||||
v, _ := version(ctx, t.Name)
|
||||
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
// sourceList returns configured winget sources.
|
||||
func sourceList(ctx context.Context) ([]snack.Repository, error) {
|
||||
args := []string{"source", "list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget source list: %w", err)
|
||||
}
|
||||
return parseSourceList(out), nil
|
||||
}
|
||||
|
||||
// sourceAdd adds a new winget source.
|
||||
func sourceAdd(ctx context.Context, repo snack.Repository) error {
|
||||
args := []string{"source", "add", "--name", repo.Name, "--arg", repo.URL}
|
||||
args = append(args, commonArgs()...)
|
||||
if repo.Type != "" {
|
||||
args = append(args, "--type", repo.Type)
|
||||
}
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
// sourceRemove removes a winget source.
|
||||
func sourceRemove(ctx context.Context, name string) error {
|
||||
args := []string{"source", "remove", "--name", name}
|
||||
args = append(args, commonArgs()...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user