mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
feat(ports): add VersionQuerier, Cleaner, FileOwner, PackageUpgrader
This commit is contained in:
60
ports/capabilities.go
Normal file
60
ports/capabilities.go
Normal 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)
|
||||
}
|
||||
146
ports/capabilities_openbsd.go
Normal file
146
ports/capabilities_openbsd.go
Normal 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
|
||||
}
|
||||
45
ports/capabilities_other.go
Normal file
45
ports/capabilities_other.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user