feat(ports): add VersionQuerier, Cleaner, FileOwner, PackageUpgrader

This commit is contained in:
2026-03-05 23:19:45 +00:00
parent a1d13e8a7d
commit b6b50491e2
6 changed files with 381 additions and 0 deletions

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