3 Commits

13 changed files with 594 additions and 3 deletions

View File

@@ -8,8 +8,9 @@ import (
// Compile-time interface checks.
var (
_ snack.Cleaner = (*Flatpak)(nil)
_ snack.RepoManager = (*Flatpak)(nil)
_ snack.Cleaner = (*Flatpak)(nil)
_ snack.RepoManager = (*Flatpak)(nil)
_ snack.VersionQuerier = (*Flatpak)(nil)
)
// Autoremove removes unused runtimes and extensions.
@@ -42,3 +43,25 @@ func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error {
defer f.Unlock()
return removeRepo(ctx, id)
}
// LatestVersion returns the latest available version of a flatpak from
// configured remotes.
func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns flatpaks that have newer versions available.
func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings using basic semver comparison.
// Flatpak has no native version comparison tool.
func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}

View File

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

View File

@@ -0,0 +1,25 @@
//go:build !linux
package flatpak
import (
"context"
"github.com/gogrlx/snack"
)
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}

View File

@@ -1,6 +1,7 @@
package flatpak
import (
"strconv"
"strings"
"github.com/gogrlx/snack"
@@ -127,3 +128,42 @@ func parseRemotes(output string) []snack.Repository {
}
return repos
}
// 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
}

60
ports/capabilities.go Normal file
View File

@@ -0,0 +1,60 @@
package ports
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*Ports)(nil)
_ snack.Cleaner = (*Ports)(nil)
_ snack.FileOwner = (*Ports)(nil)
)
// LatestVersion returns the latest available version from configured repositories.
func (p *Ports) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (p *Ports) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// 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.
func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached package files.
func (p *Ports) Clean(ctx context.Context) error {
p.Lock()
defer p.Unlock()
return clean(ctx)
}
// FileList returns all files installed by a package.
func (p *Ports) FileList(ctx context.Context, pkg string) ([]string, error) {
return fileList(ctx, pkg)
}
// Owner returns the package that owns a given file path.
func (p *Ports) Owner(ctx context.Context, path string) (string, error) {
return owner(ctx, path)
}

View File

@@ -0,0 +1,146 @@
//go:build openbsd
package ports
import (
"context"
"fmt"
"strings"
"github.com/gogrlx/snack"
)
func latestVersion(ctx context.Context, pkg string) (string, error) {
// pkg_info -Q returns available packages matching the query.
out, err := runCmd(ctx, "pkg_info", []string{"-Q", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("ports latestVersion: %w", err)
}
// Find the best match: exact name match with highest version.
var best string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, ver := splitNameVersion(line)
if name == pkg && ver != "" {
if best == "" || ver > best {
best = ver
}
}
}
if best == "" {
return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return best, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
// pkg_add -u -n simulates upgrading all packages.
out, err := runCmd(ctx, "pkg_add", []string{"-u", "-n"}, snack.Options{})
if err != nil {
// Exit status 1 with no output means nothing to upgrade.
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("ports listUpgrades: %w", err)
}
return parseUpgradeOutput(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) {
// OpenBSD has no native version comparison tool.
// Use simple string comparison.
switch {
case ver1 < ver2:
return -1, nil
case ver1 > ver2:
return 1, nil
default:
return 0, nil
}
}
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
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)
}
}
if len(toUpgrade) > 0 {
args := append([]string{"-u"}, snack.TargetNames(toUpgrade)...)
if _, err := runCmd(ctx, "pkg_add", args, o); err != nil {
return snack.InstallResult{}, 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
}

View File

@@ -0,0 +1,45 @@
//go:build !openbsd
package ports
import (
"context"
"github.com/gogrlx/snack"
)
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -105,6 +105,55 @@ func parseInfoOutput(output string, pkg string) *snack.Package {
return p
}
// parseUpgradeOutput parses the output of `pkg_add -u -n`.
// Lines like "name-oldver -> name-newver" indicate available upgrades.
func parseUpgradeOutput(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "->") {
continue
}
parts := strings.Fields(line)
// Expect: "name-oldver -> name-newver"
if len(parts) < 3 || parts[1] != "->" {
continue
}
name, _ := splitNameVersion(parts[0])
_, newVer := splitNameVersion(parts[2])
if name != "" {
pkgs = append(pkgs, snack.Package{
Name: name,
Version: newVer,
Installed: true,
})
}
}
return pkgs
}
// parseFileListOutput parses `pkg_info -L <pkg>` output.
// Lines starting with "/" after the header are file paths.
func parseFileListOutput(output string) []string {
var files []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "/") {
files = append(files, line)
}
}
return files
}
// parseOwnerOutput parses `pkg_info -E <path>` output.
// Returns the package name that owns the file.
func parseOwnerOutput(output string) string {
output = strings.TrimSpace(output)
// pkg_info -E returns the package name-version
name, _ := splitNameVersion(output)
return name
}
// splitNameVersion splits "name-version" at the last hyphen.
// OpenBSD packages use the last hyphen before a version number as separator.
func splitNameVersion(s string) (string, string) {

View File

@@ -83,3 +83,11 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) {
// Verify interface compliance at compile time.
var _ snack.Manager = (*Ports)(nil)
var _ snack.PackageUpgrader = (*Ports)(nil)
// UpgradePackages upgrades specific installed packages.
func (p *Ports) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
p.Lock()
defer p.Unlock()
return upgradePackages(ctx, pkgs, opts...)
}

View File

@@ -176,8 +176,81 @@ func TestSplitNameVersionLeadingHyphen(t *testing.T) {
}
}
func TestParseUpgradeOutput(t *testing.T) {
input := `quirks-7.14 -> quirks-7.18
curl-8.5.0 -> curl-8.6.0
python-3.11.7p0 -> python-3.11.8p0
`
pkgs := parseUpgradeOutput(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "quirks" || pkgs[0].Version != "7.18" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[1].Name != "curl" || pkgs[1].Version != "8.6.0" {
t.Errorf("unexpected second package: %+v", pkgs[1])
}
if pkgs[2].Name != "python" || pkgs[2].Version != "3.11.8p0" {
t.Errorf("unexpected third package: %+v", pkgs[2])
}
}
func TestParseUpgradeOutputEmpty(t *testing.T) {
pkgs := parseUpgradeOutput("")
if len(pkgs) != 0 {
t.Fatalf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseFileListOutput(t *testing.T) {
input := `Information for curl-8.5.0:
Files:
/usr/local/bin/curl
/usr/local/include/curl/curl.h
/usr/local/lib/libcurl.so.26.0
/usr/local/man/man1/curl.1
`
files := parseFileListOutput(input)
if len(files) != 4 {
t.Fatalf("expected 4 files, got %d", len(files))
}
if files[0] != "/usr/local/bin/curl" {
t.Errorf("unexpected first file: %q", files[0])
}
}
func TestParseFileListOutputEmpty(t *testing.T) {
files := parseFileListOutput("")
if len(files) != 0 {
t.Fatalf("expected 0 files, got %d", len(files))
}
}
func TestParseOwnerOutput(t *testing.T) {
tests := []struct {
input string
want string
}{
{"curl-8.5.0", "curl"},
{"python-3.11.7p0", "python"},
{"", ""},
}
for _, tt := range tests {
got := parseOwnerOutput(tt.input)
if got != tt.want {
t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Ports)(nil)
var _ snack.VersionQuerier = (*Ports)(nil)
var _ snack.Cleaner = (*Ports)(nil)
var _ snack.FileOwner = (*Ports)(nil)
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestName(t *testing.T) {

View File

@@ -7,7 +7,10 @@ import (
)
// Compile-time interface checks.
var _ snack.VersionQuerier = (*Snap)(nil)
var (
_ snack.VersionQuerier = (*Snap)(nil)
_ snack.Cleaner = (*Snap)(nil)
)
// LatestVersion returns the latest stable version of a snap.
func (s *Snap) LatestVersion(ctx context.Context, pkg string) (string, error) {
@@ -28,3 +31,16 @@ func (s *Snap) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
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...)
}
// Clean removes old disabled snap revisions to free disk space.
func (s *Snap) Clean(ctx context.Context) error {
s.Lock()
defer s.Unlock()
return clean(ctx)
}

View File

@@ -232,6 +232,48 @@ 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 {
out, err := run(ctx, []string{"list", "--all"})
if err != nil {
return fmt.Errorf("snap clean: %w", 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
}
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)
}
}
return nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target

View File

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