From f2eff01ab49d9931e737919a005f05a34f312be4 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sat, 28 Feb 2026 07:26:11 +0000 Subject: [PATCH] feat: add PackageUpgrader interface for targeted package upgrades Add optional PackageUpgrader interface with UpgradePackages method that upgrades specific installed packages (unlike Upgrade which upgrades everything). Each backend uses native upgrade commands: - apt: apt-get install --only-upgrade - dnf: dnf upgrade - pacman: pacman -S - apk: apk upgrade - pkg (FreeBSD): pkg upgrade - flatpak: flatpak update - snap: snap refresh Non-installed packages are filtered out and returned as Unchanged. Closes #31 --- apk/apk.go | 8 +++++++ apk/apk_linux.go | 33 ++++++++++++++++++++++++++++ apk/apk_other.go | 4 ++++ apt/apt.go | 26 ++++++++++++++-------- apt/apt_linux.go | 47 ++++++++++++++++++++++++++++++++++++++++ apt/apt_other.go | 4 ++++ dnf/dnf.go | 8 +++++++ dnf/dnf_linux.go | 34 +++++++++++++++++++++++++++++ dnf/dnf_other.go | 4 ++++ flatpak/flatpak.go | 8 +++++++ flatpak/flatpak_linux.go | 38 ++++++++++++++++++++++++++++++++ flatpak/flatpak_other.go | 4 ++++ pacman/pacman.go | 8 +++++++ pacman/pacman_linux.go | 33 ++++++++++++++++++++++++++++ pacman/pacman_other.go | 4 ++++ pkg/pkg.go | 8 +++++++ pkg/pkg_freebsd.go | 33 ++++++++++++++++++++++++++++ pkg/pkg_other.go | 4 ++++ snack.go | 12 ++++++++++ snap/snap.go | 8 +++++++ snap/snap_linux.go | 37 +++++++++++++++++++++++++++++++ snap/snap_other.go | 4 ++++ 22 files changed, 360 insertions(+), 9 deletions(-) diff --git a/apk/apk.go b/apk/apk.go index 2e47ddb..ddfa2a4 100644 --- a/apk/apk.go +++ b/apk/apk.go @@ -19,6 +19,7 @@ func New() *Apk { // compile-time check var _ snack.Manager = (*Apk)(nil) +var _ snack.PackageUpgrader = (*Apk)(nil) // Name returns "apk". func (a *Apk) Name() string { return "apk" } @@ -85,3 +86,10 @@ func (a *Apk) IsInstalled(ctx context.Context, pkg string) (bool, error) { func (a *Apk) Version(ctx context.Context, pkg string) (string, error) { return version(ctx, pkg) } + +// UpgradePackages upgrades specific installed packages. +func (a *Apk) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + a.Lock() + defer a.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/apk/apk_linux.go b/apk/apk_linux.go index 0010bd9..0ebb9c0 100644 --- a/apk/apk_linux.go +++ b/apk/apk_linux.go @@ -216,3 +216,36 @@ func version(ctx context.Context, pkg string) (string, error) { } return ver, 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{"upgrade"}, formatTargets(toUpgrade)...) + if _, err := run(ctx, args, opts...); 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 +} diff --git a/apk/apk_other.go b/apk/apk_other.go index 310c588..405ee06 100644 --- a/apk/apk_other.go +++ b/apk/apk_other.go @@ -49,3 +49,7 @@ func isInstalled(_ context.Context, _ string) (bool, error) { func version(_ context.Context, _ string) (string, error) { return "", snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/apt/apt.go b/apt/apt.go index 3f2f55d..ba3ece7 100644 --- a/apt/apt.go +++ b/apt/apt.go @@ -206,13 +206,21 @@ func (a *Apt) SupportsDryRun() bool { return true } // Compile-time interface checks. var ( - _ snack.Manager = (*Apt)(nil) - _ snack.VersionQuerier = (*Apt)(nil) - _ snack.Holder = (*Apt)(nil) - _ snack.Cleaner = (*Apt)(nil) - _ snack.FileOwner = (*Apt)(nil) - _ snack.RepoManager = (*Apt)(nil) - _ snack.KeyManager = (*Apt)(nil) - _ snack.NameNormalizer = (*Apt)(nil) - _ snack.DryRunner = (*Apt)(nil) + _ snack.Manager = (*Apt)(nil) + _ snack.VersionQuerier = (*Apt)(nil) + _ snack.Holder = (*Apt)(nil) + _ snack.Cleaner = (*Apt)(nil) + _ snack.FileOwner = (*Apt)(nil) + _ snack.RepoManager = (*Apt)(nil) + _ snack.KeyManager = (*Apt)(nil) + _ snack.NameNormalizer = (*Apt)(nil) + _ snack.DryRunner = (*Apt)(nil) + _ snack.PackageUpgrader = (*Apt)(nil) ) + +// UpgradePackages upgrades specific installed packages. +func (a *Apt) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + a.Lock() + defer a.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/apt/apt_linux.go b/apt/apt_linux.go index 2f6fb61..e7cd8b1 100644 --- a/apt/apt_linux.go +++ b/apt/apt_linux.go @@ -211,3 +211,50 @@ func version(ctx context.Context, pkg string) (string, error) { } return v, 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 { + // Use --only-upgrade to ensure we don't install new packages. + args := buildArgs("install", toUpgrade, opts...) + idx := -1 + for i, a := range args { + if a == "install" { + idx = i + break + } + } + if idx >= 0 { + args = append(args[:idx+1], append([]string{"--only-upgrade"}, args[idx+1:]...)...) + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return snack.InstallResult{}, fmt.Errorf("apt-get install --only-upgrade: %w: %s", err, stderr.String()) + } + } + 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 +} diff --git a/apt/apt_other.go b/apt/apt_other.go index 1f5f38e..8b1ce6f 100644 --- a/apt/apt_other.go +++ b/apt/apt_other.go @@ -49,3 +49,7 @@ func isInstalled(_ context.Context, _ string) (bool, error) { func version(_ context.Context, _ string) (string, error) { return "", snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/dnf/dnf.go b/dnf/dnf.go index bfe8fde..4f1858b 100644 --- a/dnf/dnf.go +++ b/dnf/dnf.go @@ -93,3 +93,11 @@ func (d *DNF) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*DNF)(nil) +var _ snack.PackageUpgrader = (*DNF)(nil) + +// UpgradePackages upgrades specific installed packages. +func (d *DNF) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + d.Lock() + defer d.Unlock() + return upgradePackages(ctx, d.v5, pkgs, opts...) +} diff --git a/dnf/dnf_linux.go b/dnf/dnf_linux.go index 3c98860..b9b0ddc 100644 --- a/dnf/dnf_linux.go +++ b/dnf/dnf_linux.go @@ -269,3 +269,37 @@ func version(ctx context.Context, pkg string, v5 bool) (string, error) { } return pkgs[0].Version, nil } + +func upgradePackages(ctx context.Context, v5 bool, 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, v5) + if err != nil { + return snack.InstallResult{}, err + } + if !ok { + unchanged = append(unchanged, t.Name) + } else { + toUpgrade = append(toUpgrade, t) + } + } + if len(toUpgrade) > 0 { + base := []string{"upgrade"} + args := append(base, formatTargets(toUpgrade)...) + if _, err := run(ctx, args, o); err != nil { + return snack.InstallResult{}, err + } + } + var upgraded []snack.Package + for _, t := range toUpgrade { + v, _ := version(ctx, t.Name, v5) + upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true}) + } + return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil +} diff --git a/dnf/dnf_other.go b/dnf/dnf_other.go index 49a648f..a9768d1 100644 --- a/dnf/dnf_other.go +++ b/dnf/dnf_other.go @@ -47,3 +47,7 @@ func isInstalled(_ context.Context, _ string, _ bool) (bool, error) { func version(_ context.Context, _ string, _ bool) (string, error) { return "", snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ bool, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/flatpak/flatpak.go b/flatpak/flatpak.go index 20f5c76..3978f72 100644 --- a/flatpak/flatpak.go +++ b/flatpak/flatpak.go @@ -83,3 +83,11 @@ func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*Flatpak)(nil) +var _ snack.PackageUpgrader = (*Flatpak)(nil) + +// UpgradePackages upgrades specific installed packages. +func (f *Flatpak) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + f.Lock() + defer f.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/flatpak/flatpak_linux.go b/flatpak/flatpak_linux.go index 6e7c75c..6edb831 100644 --- a/flatpak/flatpak_linux.go +++ b/flatpak/flatpak_linux.go @@ -197,3 +197,41 @@ func removeRepo(ctx context.Context, id string) error { _, err := run(ctx, []string{"remote-delete", id}) return err } + +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 { + 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()) + } + } + } + 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 +} diff --git a/flatpak/flatpak_other.go b/flatpak/flatpak_other.go index 81b6e11..ef10714 100644 --- a/flatpak/flatpak_other.go +++ b/flatpak/flatpak_other.go @@ -61,3 +61,7 @@ func addRepo(_ context.Context, _ snack.Repository) error { 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 +} diff --git a/pacman/pacman.go b/pacman/pacman.go index 2e7a79e..236498b 100644 --- a/pacman/pacman.go +++ b/pacman/pacman.go @@ -85,3 +85,11 @@ func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*Pacman)(nil) +var _ snack.PackageUpgrader = (*Pacman)(nil) + +// UpgradePackages upgrades specific installed packages. +func (p *Pacman) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + p.Lock() + defer p.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/pacman/pacman_linux.go b/pacman/pacman_linux.go index 8a1e7b6..071c5be 100644 --- a/pacman/pacman_linux.go +++ b/pacman/pacman_linux.go @@ -219,3 +219,36 @@ func version(ctx context.Context, pkg string) (string, error) { } return parts[1], 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{"-S"}, formatTargets(toUpgrade)...) + if _, err := run(ctx, 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 +} diff --git a/pacman/pacman_other.go b/pacman/pacman_other.go index 69459a3..095b100 100644 --- a/pacman/pacman_other.go +++ b/pacman/pacman_other.go @@ -49,3 +49,7 @@ func isInstalled(_ context.Context, _ string) (bool, error) { func version(_ context.Context, _ string) (string, error) { return "", snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/pkg/pkg.go b/pkg/pkg.go index 5ee528a..0455124 100644 --- a/pkg/pkg.go +++ b/pkg/pkg.go @@ -85,3 +85,11 @@ func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*Pkg)(nil) +var _ snack.PackageUpgrader = (*Pkg)(nil) + +// UpgradePackages upgrades specific installed packages. +func (p *Pkg) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + p.Lock() + defer p.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/pkg/pkg_freebsd.go b/pkg/pkg_freebsd.go index c527ef1..619492c 100644 --- a/pkg/pkg_freebsd.go +++ b/pkg/pkg_freebsd.go @@ -197,3 +197,36 @@ func version(ctx context.Context, pkg string) (string, error) { } return v, 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{"upgrade", "-y"}, snack.TargetNames(toUpgrade)...) + if _, err := run(ctx, 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 +} diff --git a/pkg/pkg_other.go b/pkg/pkg_other.go index fa6e513..2f3a46d 100644 --- a/pkg/pkg_other.go +++ b/pkg/pkg_other.go @@ -49,3 +49,7 @@ func isInstalled(_ context.Context, _ string) (bool, error) { func version(_ context.Context, _ string) (string, error) { return "", snack.ErrUnsupportedPlatform } + +func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, snack.ErrUnsupportedPlatform +} diff --git a/snack.go b/snack.go index 1874e30..a430dab 100644 --- a/snack.go +++ b/snack.go @@ -220,3 +220,15 @@ type DryRunner interface { // SupportsDryRun reports whether this backend honors [WithDryRun]. SupportsDryRun() bool } + +// PackageUpgrader provides targeted package upgrades (as opposed to Upgrade +// which upgrades everything). Backends that implement this use native upgrade +// commands that only act on already-installed packages. +// +// Supported by: apt, dnf, pacman, apk, pkg (FreeBSD), flatpak, snap. +type PackageUpgrader interface { + // UpgradePackages upgrades specific installed packages to their latest + // versions. Packages that are not installed are skipped (not installed). + // Returns an InstallResult describing what changed. + UpgradePackages(ctx context.Context, pkgs []Target, opts ...Option) (InstallResult, error) +} diff --git a/snap/snap.go b/snap/snap.go index 36a8f97..9e0bad0 100644 --- a/snap/snap.go +++ b/snap/snap.go @@ -83,3 +83,11 @@ func (s *Snap) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*Snap)(nil) +var _ snack.PackageUpgrader = (*Snap)(nil) + +// UpgradePackages upgrades specific installed packages. +func (s *Snap) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + s.Lock() + defer s.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/snap/snap_linux.go b/snap/snap_linux.go index 00bfa63..746dd85 100644 --- a/snap/snap_linux.go +++ b/snap/snap_linux.go @@ -231,3 +231,40 @@ func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { 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) + } + } + 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()) + } + } + } + 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 +} diff --git a/snap/snap_other.go b/snap/snap_other.go index a843336..4bdf614 100644 --- a/snap/snap_other.go +++ b/snap/snap_other.go @@ -65,3 +65,7 @@ func upgradeAvailable(_ context.Context, _ string) (bool, error) { 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 +}