feat: add capability interfaces for extended package manager operations

Add optional interfaces that providers can implement beyond the base
Manager. grlx can type-assert to check support at runtime:

- VersionQuerier: LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
- Holder: Hold, Unhold, ListHeld (version pinning)
- Cleaner: Autoremove, Clean (orphan/cache cleanup)
- FileOwner: FileList, Owner (file-to-package mapping)
- RepoManager: ListRepos, AddRepo, RemoveRepo
- KeyManager: AddKey, RemoveKey, ListKeys (GPG signing keys)
- Grouper: GroupList, GroupInfo, GroupInstall
- NameNormalizer: NormalizeName, ParseArch

Also adds GetCapabilities() helper, Repository type, and updated README.
This commit is contained in:
2026-02-25 20:42:04 +00:00
parent 0d6c5d9e17
commit ef81e027ce
4 changed files with 195 additions and 1 deletions

View File

@@ -77,10 +77,40 @@ if err != nil {
fmt.Println("Detected:", mgr.Name())
```
## Interfaces
snack uses a layered interface design. Every provider implements `Manager` (the base). Extended capabilities are optional — use type assertions to check support:
```go
// Base — every provider
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
// Optional capabilities — type-assert to check
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
snack.Holder // Hold, Unhold, ListHeld (version pinning)
snack.Cleaner // Autoremove, Clean (orphan/cache cleanup)
snack.FileOwner // FileList, Owner (file-to-package queries)
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys)
snack.Grouper // GroupList, GroupInfo, GroupInstall
snack.NameNormalizer // NormalizeName, ParseArch
```
Check capabilities at runtime:
```go
caps := snack.GetCapabilities(mgr)
if caps.Hold {
mgr.(snack.Holder).Hold(ctx, []string{"nginx"})
}
```
## Design
- **Thin CLI wrappers** — each sub-package wraps a package manager's CLI tools. No FFI, no library bindings.
- **Common interface** — all managers implement `snack.Manager`, making them interchangeable.
- **Capability interfaces** — extended features via type assertion, so providers aren't forced to stub unsupported operations.
- **Per-provider mutex** — each provider serializes mutating operations independently; apt + snap can run in parallel.
- **Context-aware** — all operations accept `context.Context` for cancellation and timeouts.
- **Platform-safe** — build tags ensure packages compile everywhere but only run where appropriate.
- **No root assumption** — use `snack.WithSudo()` when elevated privileges are needed.

37
capabilities.go Normal file
View File

@@ -0,0 +1,37 @@
package snack
// Capabilities reports which optional interfaces a Manager implements.
// Useful for grlx to determine what operations are available before
// attempting them.
type Capabilities struct {
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
}
// GetCapabilities probes a Manager for all optional interface support.
func GetCapabilities(m Manager) Capabilities {
_, vq := m.(VersionQuerier)
_, h := m.(Holder)
_, c := m.(Cleaner)
_, fo := m.(FileOwner)
_, rm := m.(RepoManager)
_, km := m.(KeyManager)
_, g := m.(Grouper)
_, nn := m.(NameNormalizer)
return Capabilities{
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
}
}

117
snack.go
View File

@@ -46,7 +46,17 @@ func Targets(names ...string) []Target {
return targets
}
// Manager is the common interface implemented by all package manager wrappers.
// Manager is the base interface implemented by all package manager wrappers.
// It covers the core operations every package manager supports.
//
// For extended capabilities, use type assertions against the optional
// interfaces below. Example:
//
// if holder, ok := mgr.(snack.Holder); ok {
// holder.Hold(ctx, "nginx")
// } else {
// log.Warn("hold not supported by", mgr.Name())
// }
type Manager interface {
// Install one or more packages.
Install(ctx context.Context, pkgs []Target, opts ...Option) error
@@ -84,3 +94,108 @@ type Manager interface {
// Name returns the package manager's identifier (e.g. "apt", "pacman").
Name() string
}
// VersionQuerier provides version comparison and upgrade availability checks.
// Supported by: apt, pacman, apk, dnf.
type VersionQuerier interface {
// LatestVersion returns the latest available version of a package
// from configured repositories.
LatestVersion(ctx context.Context, pkg string) (string, error)
// ListUpgrades returns packages that have newer versions available.
ListUpgrades(ctx context.Context) ([]Package, error)
// UpgradeAvailable reports whether a newer version of a package is
// available in the repositories.
UpgradeAvailable(ctx context.Context, pkg string) (bool, error)
// VersionCmp compares two version strings using the package manager's
// native version comparison logic. Returns -1, 0, or 1.
VersionCmp(ctx context.Context, ver1, ver2 string) (int, error)
}
// Holder provides package version pinning (hold/unhold).
// Supported by: apt, pacman (with pacman-contrib), dnf.
type Holder interface {
// Hold pins packages at their current version, preventing upgrades.
Hold(ctx context.Context, pkgs []string) error
// Unhold removes version pins, allowing packages to be upgraded.
Unhold(ctx context.Context, pkgs []string) error
// ListHeld returns all currently held/pinned packages.
ListHeld(ctx context.Context) ([]Package, error)
}
// Cleaner provides orphan/cache cleanup operations.
// Supported by: apt, pacman, apk, dnf.
type Cleaner interface {
// Autoremove removes packages that were installed as dependencies
// but are no longer required by any installed package.
Autoremove(ctx context.Context, opts ...Option) error
// Clean removes cached package files from the local cache.
Clean(ctx context.Context) error
}
// FileOwner provides file-to-package ownership queries.
// Supported by: apt/dpkg, pacman, rpm/dnf, apk.
type FileOwner interface {
// FileList returns all files installed by a package.
FileList(ctx context.Context, pkg string) ([]string, error)
// Owner returns the package that owns a given file path.
Owner(ctx context.Context, path string) (string, error)
}
// RepoManager provides repository configuration operations.
// Supported by: apt, dnf, pacman (partially).
type RepoManager interface {
// ListRepos returns all configured package repositories.
ListRepos(ctx context.Context) ([]Repository, error)
// AddRepo adds a new package repository.
AddRepo(ctx context.Context, repo Repository) error
// RemoveRepo removes a configured repository.
RemoveRepo(ctx context.Context, id string) error
}
// KeyManager provides GPG/signing key management for repositories.
// Supported by: apt, rpm/dnf.
type KeyManager interface {
// AddKey imports a GPG key for package verification.
// The key can be a URL, file path, or key ID.
AddKey(ctx context.Context, key string) error
// RemoveKey removes a GPG key.
RemoveKey(ctx context.Context, keyID string) error
// ListKeys returns all trusted package signing keys.
ListKeys(ctx context.Context) ([]string, error)
}
// Grouper provides package group operations.
// Supported by: pacman, dnf/yum.
type Grouper interface {
// GroupList returns all available package groups.
GroupList(ctx context.Context) ([]string, error)
// GroupInfo returns the packages in a group.
GroupInfo(ctx context.Context, group string) ([]Package, error)
// GroupInstall installs all packages in a group.
GroupInstall(ctx context.Context, group string, opts ...Option) error
}
// NormalizeName provides package name normalization.
// Supported by: apt (strips :arch suffixes), rpm.
type NameNormalizer interface {
// NormalizeName returns the canonical form of a package name,
// stripping architecture suffixes, epoch prefixes, etc.
NormalizeName(name string) string
// ParseArch extracts the architecture from a package name if present.
// Returns the name without arch and the arch string.
ParseArch(name string) (string, string)
}

View File

@@ -67,6 +67,18 @@ func WithReinstall() Option {
return func(o *Options) { o.Reinstall = true }
}
// Repository represents a configured package repository.
type Repository struct {
ID string `json:"id"` // unique identifier
Name string `json:"name,omitempty"` // human-readable name
URL string `json:"url"` // repository URL
Enabled bool `json:"enabled"` // whether the repo is active
GPGCheck bool `json:"gpg_check,omitempty"` // whether GPG verification is enabled
GPGKey string `json:"gpg_key,omitempty"` // GPG key URL or ID
Type string `json:"type,omitempty"` // e.g. "deb", "rpm-md", "pkg"
Arch string `json:"arch,omitempty"` // architecture filter
}
// ApplyOptions processes functional options into an Options struct.
func ApplyOptions(opts ...Option) Options {
var o Options