feat: add per-provider mutex and Target-aware implementations

- Add snack.Locker embed for per-provider mutex serialization
- Update all providers (pacman, apk, apt, dpkg) to use []Target
  with version pinning support (pkg=version syntax)
- Add lock/unlock to all mutating operations (Install, Remove, Purge,
  Upgrade, Update)
- Add snack.TargetNames helper and formatTargets per provider
- apt: add FromRepo (-t) and Reinstall support
- dpkg: use Target.Source for .deb file paths in Install
This commit is contained in:
2026-02-25 20:35:45 +00:00
parent 6cbfc96e3d
commit 0d6c5d9e17
14 changed files with 198 additions and 52 deletions

View File

@@ -8,7 +8,9 @@ import (
) )
// Apk wraps apk-tools operations. // Apk wraps apk-tools operations.
type Apk struct{} type Apk struct {
snack.Locker
}
// New returns a new Apk manager. // New returns a new Apk manager.
func New() *Apk { func New() *Apk {
@@ -25,27 +27,37 @@ func (a *Apk) Name() string { return "apk" }
func (a *Apk) Available() bool { return available() } func (a *Apk) Available() bool { return available() }
// Install one or more packages. // Install one or more packages.
func (a *Apk) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apk) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return install(ctx, pkgs, opts...) return install(ctx, pkgs, opts...)
} }
// Remove one or more packages. // Remove one or more packages.
func (a *Apk) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apk) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return remove(ctx, pkgs, opts...) return remove(ctx, pkgs, opts...)
} }
// Purge removes packages including config files. // Purge removes packages including config files.
func (a *Apk) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apk) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return purge(ctx, pkgs, opts...) return purge(ctx, pkgs, opts...)
} }
// Upgrade all installed packages. // Upgrade all installed packages.
func (a *Apk) Upgrade(ctx context.Context, opts ...snack.Option) error { func (a *Apk) Upgrade(ctx context.Context, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return upgrade(ctx, opts...) return upgrade(ctx, opts...)
} }
// Update refreshes the package index. // Update refreshes the package index.
func (a *Apk) Update(ctx context.Context) error { func (a *Apk) Update(ctx context.Context) error {
a.Lock()
defer a.Unlock()
return update(ctx) return update(ctx)
} }

View File

@@ -52,20 +52,34 @@ func run(ctx context.Context, base []string, opts ...snack.Option) (string, erro
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { // formatTargets converts targets to apk CLI arguments.
args := append([]string{"add"}, pkgs...) // apk uses "pkg=version" for version pinning.
func formatTargets(targets []snack.Target) []string {
args := make([]string, 0, len(targets))
for _, t := range targets {
if t.Version != "" {
args = append(args, t.Name+"="+t.Version)
} else {
args = append(args, t.Name)
}
}
return args
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
args := append([]string{"add"}, formatTargets(pkgs)...)
_, err := run(ctx, args, opts...) _, err := run(ctx, args, opts...)
return err return err
} }
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
args := append([]string{"del"}, pkgs...) args := append([]string{"del"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, opts...) _, err := run(ctx, args, opts...)
return err return err
} }
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
args := append([]string{"del", "--purge"}, pkgs...) args := append([]string{"del", "--purge"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, opts...) _, err := run(ctx, args, opts...)
return err return err
} }

View File

@@ -10,15 +10,15 @@ import (
func available() bool { return false } func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error { func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func remove(_ context.Context, _ []string, _ ...snack.Option) error { func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func purge(_ context.Context, _ []string, _ ...snack.Option) error { func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }

View File

@@ -8,7 +8,9 @@ import (
) )
// Apt implements the snack.Manager interface using apt-get and apt-cache. // Apt implements the snack.Manager interface using apt-get and apt-cache.
type Apt struct{} type Apt struct {
snack.Locker
}
// New returns a new Apt manager. // New returns a new Apt manager.
func New() *Apt { func New() *Apt {
@@ -19,27 +21,37 @@ func New() *Apt {
func (a *Apt) Name() string { return "apt" } func (a *Apt) Name() string { return "apt" }
// Install one or more packages. // Install one or more packages.
func (a *Apt) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apt) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return install(ctx, pkgs, opts...) return install(ctx, pkgs, opts...)
} }
// Remove one or more packages. // Remove one or more packages.
func (a *Apt) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apt) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return remove(ctx, pkgs, opts...) return remove(ctx, pkgs, opts...)
} }
// Purge one or more packages including config files. // Purge one or more packages including config files.
func (a *Apt) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (a *Apt) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return purge(ctx, pkgs, opts...) return purge(ctx, pkgs, opts...)
} }
// Upgrade all installed packages. // Upgrade all installed packages.
func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error { func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return upgrade(ctx, opts...) return upgrade(ctx, opts...)
} }
// Update refreshes the package index. // Update refreshes the package index.
func (a *Apt) Update(ctx context.Context) error { func (a *Apt) Update(ctx context.Context) error {
a.Lock()
defer a.Unlock()
return update(ctx) return update(ctx)
} }

View File

@@ -17,7 +17,21 @@ func available() bool {
return err == nil return err == nil
} }
func buildArgs(command string, pkgs []string, opts ...snack.Option) []string { // formatTargets converts targets to apt CLI arguments.
// apt uses "pkg=version" for version pinning.
func formatTargets(targets []snack.Target) []string {
args := make([]string, 0, len(targets))
for _, t := range targets {
if t.Version != "" {
args = append(args, t.Name+"="+t.Version)
} else {
args = append(args, t.Name)
}
}
return args
}
func buildArgs(command string, pkgs []snack.Target, opts ...snack.Option) []string {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
var args []string var args []string
if o.Sudo { if o.Sudo {
@@ -30,11 +44,17 @@ func buildArgs(command string, pkgs []string, opts ...snack.Option) []string {
if o.DryRun { if o.DryRun {
args = append(args, "--dry-run") args = append(args, "--dry-run")
} }
args = append(args, pkgs...) if o.FromRepo != "" {
args = append(args, "-t", o.FromRepo)
}
if o.Reinstall && command == "install" {
args = append(args, "--reinstall")
}
args = append(args, formatTargets(pkgs)...)
return args return args
} }
func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack.Option) error { func runAptGet(ctx context.Context, command string, pkgs []snack.Target, opts ...snack.Option) error {
args := buildArgs(command, pkgs, opts...) args := buildArgs(command, pkgs, opts...)
var cmd *exec.Cmd var cmd *exec.Cmd
if args[0] == "sudo" { if args[0] == "sudo" {
@@ -57,15 +77,15 @@ func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack
return nil return nil
} }
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
return runAptGet(ctx, "install", pkgs, opts...) return runAptGet(ctx, "install", pkgs, opts...)
} }
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
return runAptGet(ctx, "remove", pkgs, opts...) return runAptGet(ctx, "remove", pkgs, opts...)
} }
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
return runAptGet(ctx, "purge", pkgs, opts...) return runAptGet(ctx, "purge", pkgs, opts...)
} }

View File

@@ -10,15 +10,15 @@ import (
func available() bool { return false } func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error { func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func remove(_ context.Context, _ []string, _ ...snack.Option) error { func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func purge(_ context.Context, _ []string, _ ...snack.Option) error { func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }

View File

@@ -8,7 +8,9 @@ import (
) )
// Dpkg implements the snack.Manager interface using dpkg and dpkg-query. // Dpkg implements the snack.Manager interface using dpkg and dpkg-query.
type Dpkg struct{} type Dpkg struct {
snack.Locker
}
// New returns a new Dpkg manager. // New returns a new Dpkg manager.
func New() *Dpkg { func New() *Dpkg {
@@ -19,17 +21,23 @@ func New() *Dpkg {
func (d *Dpkg) Name() string { return "dpkg" } func (d *Dpkg) Name() string { return "dpkg" }
// Install one or more .deb files. // Install one or more .deb files.
func (d *Dpkg) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (d *Dpkg) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return install(ctx, pkgs, opts...) return install(ctx, pkgs, opts...)
} }
// Remove one or more packages. // Remove one or more packages.
func (d *Dpkg) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (d *Dpkg) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return remove(ctx, pkgs, opts...) return remove(ctx, pkgs, opts...)
} }
// Purge one or more packages including config files. // Purge one or more packages including config files.
func (d *Dpkg) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (d *Dpkg) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return purge(ctx, pkgs, opts...) return purge(ctx, pkgs, opts...)
} }

View File

@@ -17,7 +17,7 @@ func available() bool {
return err == nil return err == nil
} }
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
var args []string var args []string
if o.Sudo { if o.Sudo {
@@ -27,7 +27,14 @@ func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
if o.DryRun { if o.DryRun {
args = append(args, "--simulate") args = append(args, "--simulate")
} }
args = append(args, pkgs...) // dpkg -i takes file paths; use Source if set, otherwise Name
for _, t := range pkgs {
if t.Source != "" {
args = append(args, t.Source)
} else {
args = append(args, t.Name)
}
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -41,7 +48,7 @@ func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return nil return nil
} }
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
var args []string var args []string
if o.Sudo { if o.Sudo {
@@ -51,7 +58,7 @@ func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
if o.DryRun { if o.DryRun {
args = append(args, "--simulate") args = append(args, "--simulate")
} }
args = append(args, pkgs...) args = append(args, snack.TargetNames(pkgs)...)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -61,7 +68,7 @@ func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return nil return nil
} }
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
var args []string var args []string
if o.Sudo { if o.Sudo {
@@ -71,7 +78,7 @@ func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
if o.DryRun { if o.DryRun {
args = append(args, "--simulate") args = append(args, "--simulate")
} }
args = append(args, pkgs...) args = append(args, snack.TargetNames(pkgs)...)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr

View File

@@ -10,15 +10,15 @@ import (
func available() bool { return false } func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error { func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func remove(_ context.Context, _ []string, _ ...snack.Option) error { func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func purge(_ context.Context, _ []string, _ ...snack.Option) error { func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }

27
mutex.go Normal file
View File

@@ -0,0 +1,27 @@
package snack
import "sync"
// Locker is an embeddable type that provides per-provider mutex locking.
// Each package manager backend should embed this in its struct to serialize
// mutating operations (Install, Remove, Purge, Upgrade, Update).
//
// Read-only operations (List, Search, Info, IsInstalled, Version) generally
// don't need the lock, but backends may choose to lock them if the underlying
// tool doesn't support concurrent reads.
//
// Different providers use independent locks, so an apt Install and a snap
// Install can run concurrently, but two apt Installs will serialize.
type Locker struct {
mu sync.Mutex
}
// Lock acquires the provider lock. Call before mutating operations.
func (l *Locker) Lock() {
l.mu.Lock()
}
// Unlock releases the provider lock. Defer after Lock.
func (l *Locker) Unlock() {
l.mu.Unlock()
}

View File

@@ -8,7 +8,9 @@ import (
) )
// Pacman wraps the pacman package manager CLI. // Pacman wraps the pacman package manager CLI.
type Pacman struct{} type Pacman struct {
snack.Locker
}
// New returns a new Pacman manager. // New returns a new Pacman manager.
func New() *Pacman { func New() *Pacman {
@@ -22,27 +24,37 @@ func (p *Pacman) Name() string { return "pacman" }
func (p *Pacman) Available() bool { return available() } func (p *Pacman) Available() bool { return available() }
// Install one or more packages. // Install one or more packages.
func (p *Pacman) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (p *Pacman) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return install(ctx, pkgs, opts...) return install(ctx, pkgs, opts...)
} }
// Remove one or more packages. // Remove one or more packages.
func (p *Pacman) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (p *Pacman) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return remove(ctx, pkgs, opts...) return remove(ctx, pkgs, opts...)
} }
// Purge removes packages including configuration files. // Purge removes packages including configuration files.
func (p *Pacman) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func (p *Pacman) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return purge(ctx, pkgs, opts...) return purge(ctx, pkgs, opts...)
} }
// Upgrade all installed packages to their latest versions. // Upgrade all installed packages to their latest versions.
func (p *Pacman) Upgrade(ctx context.Context, opts ...snack.Option) error { func (p *Pacman) Upgrade(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return upgrade(ctx, opts...) return upgrade(ctx, opts...)
} }
// Update refreshes the package database. // Update refreshes the package database.
func (p *Pacman) Update(ctx context.Context) error { func (p *Pacman) Update(ctx context.Context) error {
p.Lock()
defer p.Unlock()
return update(ctx) return update(ctx)
} }

View File

@@ -58,23 +58,47 @@ func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, er
return stdout.String(), nil return stdout.String(), nil
} }
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { // formatTargets converts targets to pacman CLI arguments.
// Pacman uses "pkg=version" for version pinning.
func formatTargets(targets []snack.Target) []string {
args := make([]string, 0, len(targets))
for _, t := range targets {
if t.Version != "" {
args = append(args, t.Name+"="+t.Version)
} else {
args = append(args, t.Name)
}
}
return args
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
args := append([]string{"-S", "--noconfirm"}, pkgs...) base := []string{"-S", "--noconfirm"}
if o.Refresh {
base = []string{"-Sy", "--noconfirm"}
}
for _, t := range pkgs {
if t.FromRepo != "" || o.FromRepo != "" {
// Not directly supported by pacman CLI; user should configure repos
break
}
}
args := append(base, formatTargets(pkgs)...)
_, err := run(ctx, args, o) _, err := run(ctx, args, o)
return err return err
} }
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
args := append([]string{"-R", "--noconfirm"}, pkgs...) args := append([]string{"-R", "--noconfirm"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, o) _, err := run(ctx, args, o)
return err return err
} }
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...) o := snack.ApplyOptions(opts...)
args := append([]string{"-Rns", "--noconfirm"}, pkgs...) args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, o) _, err := run(ctx, args, o)
return err return err
} }

View File

@@ -10,15 +10,15 @@ import (
func available() bool { return false } func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error { func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func remove(_ context.Context, _ []string, _ ...snack.Option) error { func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }
func purge(_ context.Context, _ []string, _ ...snack.Option) error { func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform return snack.ErrUnsupportedPlatform
} }

10
target.go Normal file
View File

@@ -0,0 +1,10 @@
package snack
// TargetNames extracts just the package names from a slice of targets.
func TargetNames(targets []Target) []string {
names := make([]string, len(targets))
for i, t := range targets {
names[i] = t.Name
}
return names
}