feat: implement detect package and snack CLI

- detect: auto-detection with Default(), All(), ByName()
  - Platform-specific probing via build tags (linux, freebsd, openbsd)
  - Linux probe order: apt, dnf, pacman, apk, flatpak, snap
- cmd/snack: full CLI with cobra + charmbracelet/fang
  - Subcommands: install, remove, purge, upgrade, update, list, search,
    info, which, hold, unhold, clean, detect, version
  - Global flags: --manager, --sudo, --yes, --dry-run
  - Capability type-assertions for FileOwner, Holder, Cleaner
- detect: basic compilation tests
This commit is contained in:
2026-02-26 01:08:27 +00:00
parent 10c8bd6f4d
commit ca2fdd49ac
15 changed files with 1243 additions and 28 deletions

View File

@@ -2,22 +2,408 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/charmbracelet/fang"
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/detect"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "snack",
Short: "A unified CLI for system package managers",
Long: `snack wraps system package managers (apt, pacman, apk, dnf, and more)
behind a single, consistent interface.`,
}
var (
version = "dev"
flagMgr string
flagSudo bool
flagYes bool
flagDry bool
)
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
rootCmd := &cobra.Command{
Use: "snack",
Short: "A unified CLI for system package managers",
Long: `snack wraps system package managers (apt, pacman, apk, dnf, and more)
behind a single, consistent interface.`,
}
rootCmd.PersistentFlags().StringVar(&flagMgr, "manager", "", "override auto-detected package manager")
rootCmd.PersistentFlags().BoolVar(&flagSudo, "sudo", false, "run with sudo")
rootCmd.PersistentFlags().BoolVar(&flagYes, "yes", false, "assume yes to prompts")
rootCmd.PersistentFlags().BoolVar(&flagDry, "dry-run", false, "simulate the operation")
rootCmd.AddCommand(
installCmd(),
removeCmd(),
purgeCmd(),
upgradeCmd(),
updateCmd(),
listCmd(),
searchCmd(),
infoCmd(),
whichCmd(),
holdCmd(),
unholdCmd(),
cleanCmd(),
detectCmd(),
versionCmd(),
)
if err := fang.Execute(context.Background(), rootCmd); err != nil {
os.Exit(1)
}
}
func getManager() (snack.Manager, error) {
if flagMgr != "" {
m, err := detect.ByName(flagMgr)
if err != nil {
return nil, fmt.Errorf("unknown manager %q", flagMgr)
}
return m, nil
}
return detect.Default()
}
func opts() []snack.Option {
var o []snack.Option
if flagSudo {
o = append(o, snack.WithSudo())
}
if flagYes {
o = append(o, snack.WithAssumeYes())
}
if flagDry {
o = append(o, snack.WithDryRun())
}
return o
}
func targets(args []string, ver string) []snack.Target {
t := snack.Targets(args...)
if ver != "" && len(t) > 0 {
for i := range t {
t[i].Version = ver
}
}
return t
}
func installCmd() *cobra.Command {
var ver string
cmd := &cobra.Command{
Use: "install <packages...>",
Short: "Install packages",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
return m.Install(cmd.Context(), targets(args, ver), opts()...)
},
}
cmd.Flags().StringVar(&ver, "version", "", "pin version for all targets")
return cmd
}
func removeCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <packages...>",
Short: "Remove packages",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
return m.Remove(cmd.Context(), snack.Targets(args...), opts()...)
},
}
}
func purgeCmd() *cobra.Command {
return &cobra.Command{
Use: "purge <packages...>",
Short: "Purge packages (remove including config files)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
return m.Purge(cmd.Context(), snack.Targets(args...), opts()...)
},
}
}
func upgradeCmd() *cobra.Command {
return &cobra.Command{
Use: "upgrade",
Short: "Upgrade all installed packages",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
return m.Upgrade(cmd.Context(), opts()...)
},
}
}
func updateCmd() *cobra.Command {
return &cobra.Command{
Use: "update",
Short: "Refresh the package index",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
return m.Update(cmd.Context())
},
}
}
func listCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List installed packages",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
pkgs, err := m.List(cmd.Context())
if err != nil {
return err
}
for _, p := range pkgs {
if p.Version != "" {
fmt.Printf("%s %s\n", p.Name, p.Version)
} else {
fmt.Println(p.Name)
}
}
return nil
},
}
}
func searchCmd() *cobra.Command {
return &cobra.Command{
Use: "search <query>",
Short: "Search for packages",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
pkgs, err := m.Search(cmd.Context(), args[0])
if err != nil {
return err
}
for _, p := range pkgs {
line := p.Name
if p.Version != "" {
line += " " + p.Version
}
if p.Description != "" {
line += " - " + p.Description
}
fmt.Println(line)
}
return nil
},
}
}
func infoCmd() *cobra.Command {
return &cobra.Command{
Use: "info <package>",
Short: "Show package information",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
p, err := m.Info(cmd.Context(), args[0])
if err != nil {
return err
}
fmt.Printf("Name: %s\n", p.Name)
fmt.Printf("Version: %s\n", p.Version)
if p.Description != "" {
fmt.Printf("Description: %s\n", p.Description)
}
if p.Arch != "" {
fmt.Printf("Arch: %s\n", p.Arch)
}
if p.Repository != "" {
fmt.Printf("Repository: %s\n", p.Repository)
}
fmt.Printf("Installed: %v\n", p.Installed)
return nil
},
}
}
func whichCmd() *cobra.Command {
return &cobra.Command{
Use: "which <path>",
Short: "Find the package that owns a file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
fo, ok := m.(snack.FileOwner)
if !ok {
return fmt.Errorf("%s does not support file ownership queries", m.Name())
}
pkg, err := fo.Owner(cmd.Context(), args[0])
if err != nil {
return err
}
fmt.Println(pkg)
return nil
},
}
}
func holdCmd() *cobra.Command {
return &cobra.Command{
Use: "hold <packages...>",
Short: "Hold packages at their current version",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
h, ok := m.(snack.Holder)
if !ok {
return fmt.Errorf("%s does not support hold/unhold", m.Name())
}
return h.Hold(cmd.Context(), args)
},
}
}
func unholdCmd() *cobra.Command {
return &cobra.Command{
Use: "unhold <packages...>",
Short: "Remove version hold from packages",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
h, ok := m.(snack.Holder)
if !ok {
return fmt.Errorf("%s does not support hold/unhold", m.Name())
}
return h.Unhold(cmd.Context(), args)
},
}
}
func cleanCmd() *cobra.Command {
return &cobra.Command{
Use: "clean",
Short: "Autoremove unused packages and clean cache",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
c, ok := m.(snack.Cleaner)
if !ok {
return fmt.Errorf("%s does not support clean operations", m.Name())
}
if err := c.Autoremove(cmd.Context(), opts()...); err != nil {
return fmt.Errorf("autoremove: %w", err)
}
if err := c.Clean(cmd.Context()); err != nil {
return fmt.Errorf("clean: %w", err)
}
return nil
},
}
}
func detectCmd() *cobra.Command {
return &cobra.Command{
Use: "detect",
Short: "Show detected package manager(s) and capabilities",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
all := detect.All()
if len(all) == 0 {
fmt.Println("No supported package managers detected.")
return nil
}
def, _ := detect.Default()
for _, m := range all {
marker := " "
if def != nil && m.Name() == def.Name() {
marker = "* "
}
caps := snack.GetCapabilities(m)
var capList []string
if caps.VersionQuery {
capList = append(capList, "version-query")
}
if caps.Hold {
capList = append(capList, "hold")
}
if caps.Clean {
capList = append(capList, "clean")
}
if caps.FileOwnership {
capList = append(capList, "file-owner")
}
if caps.RepoManagement {
capList = append(capList, "repo")
}
if caps.KeyManagement {
capList = append(capList, "keys")
}
if caps.Groups {
capList = append(capList, "groups")
}
if caps.NameNormalize {
capList = append(capList, "normalize")
}
capStr := ""
if len(capList) > 0 {
capStr = " [" + strings.Join(capList, ", ") + "]"
}
fmt.Printf("%s%s%s\n", marker, m.Name(), capStr)
}
fmt.Println("\n* = default")
return nil
},
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show snack version",
Args: cobra.NoArgs,
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("snack %s\n", version)
},
}
}

View File

@@ -7,35 +7,45 @@ import (
"github.com/gogrlx/snack"
)
// probeOrder defines the order in which package managers are probed.
// The first available manager wins.
var probeOrder = []struct {
name string
bin string
}{
{"pacman", "pacman"},
{"apk", "apk"},
{"apt", "apt-get"},
{"dnf", "dnf"},
{"rpm", "rpm"},
{"flatpak", "flatpak"},
{"snap", "snap"},
{"pkg", "pkg"},
}
// Default returns the first available package manager on the system.
// Returns ErrManagerNotFound if no supported manager is detected.
func Default() (snack.Manager, error) {
// TODO: implement — probe for each manager in order, return first match
for _, fn := range candidates() {
m := fn()
if m.Available() {
return m, nil
}
}
return nil, snack.ErrManagerNotFound
}
// All returns all available package managers on the system.
func All() []snack.Manager {
// TODO: implement
return nil
var out []snack.Manager
for _, fn := range candidates() {
m := fn()
if m.Available() {
out = append(out, m)
}
}
return out
}
// ByName returns a specific manager by name, regardless of availability.
// Returns ErrManagerNotFound if the name is not recognized.
func ByName(name string) (snack.Manager, error) {
for _, fn := range allManagers() {
m := fn()
if m.Name() == name {
return m, nil
}
}
return nil, snack.ErrManagerNotFound
}
// managerFactory is a function that returns a new Manager instance.
type managerFactory func() snack.Manager
// HasBinary reports whether a binary is available in PATH.
func HasBinary(name string) bool {
_, err := exec.LookPath(name)

18
detect/detect_freebsd.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build freebsd
package detect
import (
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/pkg"
)
func candidates() []managerFactory {
return []managerFactory{
func() snack.Manager { return pkg.New() },
}
}
func allManagers() []managerFactory {
return candidates()
}

31
detect/detect_linux.go Normal file
View File

@@ -0,0 +1,31 @@
//go:build linux
package detect
import (
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/apk"
"github.com/gogrlx/snack/apt"
"github.com/gogrlx/snack/dnf"
"github.com/gogrlx/snack/flatpak"
"github.com/gogrlx/snack/pacman"
"github.com/gogrlx/snack/snap"
)
// candidates returns manager factories in probe order for Linux.
// The first available manager wins for Default().
func candidates() []managerFactory {
return []managerFactory{
func() snack.Manager { return apt.New() },
func() snack.Manager { return dnf.New() },
func() snack.Manager { return pacman.New() },
func() snack.Manager { return apk.New() },
func() snack.Manager { return flatpak.New() },
func() snack.Manager { return snap.New() },
}
}
// allManagers returns all known manager factories (for ByName).
func allManagers() []managerFactory {
return candidates()
}

13
detect/detect_openbsd.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build openbsd
package detect
// candidates returns manager factories in probe order for OpenBSD.
// TODO: wire up ports.New() once the ports package is implemented.
func candidates() []managerFactory {
return nil
}
func allManagers() []managerFactory {
return nil
}

22
detect/detect_test.go Normal file
View File

@@ -0,0 +1,22 @@
package detect
import (
"testing"
)
func TestByNameUnknown(t *testing.T) {
_, err := ByName("nonexistent")
if err == nil {
t.Fatal("expected error for unknown manager")
}
}
func TestAllReturnsSlice(t *testing.T) {
// Just verify it doesn't panic; actual availability depends on system.
_ = All()
}
func TestDefaultDoesNotPanic(t *testing.T) {
// May return error if no managers available; that's fine.
_, _ = Default()
}

28
go.mod
View File

@@ -2,9 +2,35 @@ module github.com/gogrlx/snack
go 1.26.0
require github.com/spf13/cobra v1.10.2
require (
github.com/charmbracelet/fang v0.4.4
github.com/spf13/cobra v1.10.2
)
require (
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect
github.com/charmbracelet/x/ansi v0.11.0 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.4.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

62
go.sum
View File

@@ -1,10 +1,72 @@
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ=
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0=
github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU=
github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

58
pkg/capabilities.go Normal file
View File

@@ -0,0 +1,58 @@
package pkg
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*Pkg)(nil)
_ snack.Cleaner = (*Pkg)(nil)
_ snack.FileOwner = (*Pkg)(nil)
)
// LatestVersion returns the latest available version from configured repositories.
func (p *Pkg) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (p *Pkg) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (p *Pkg) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings using pkg's version comparison.
func (p *Pkg) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes packages no longer required as dependencies.
func (p *Pkg) Autoremove(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached package files.
func (p *Pkg) Clean(ctx context.Context) error {
p.Lock()
defer p.Unlock()
return clean(ctx)
}
// FileList returns all files installed by a package.
func (p *Pkg) FileList(ctx context.Context, pkg string) ([]string, error) {
return fileList(ctx, pkg)
}
// Owner returns the package that owns a given file path.
func (p *Pkg) Owner(ctx context.Context, path string) (string, error) {
return owner(ctx, path)
}

102
pkg/capabilities_freebsd.go Normal file
View File

@@ -0,0 +1,102 @@
//go:build freebsd
package pkg
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"rquery", "%v", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "exit status 70") {
return "", fmt.Errorf("pkg latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("pkg latestVersion: %w", err)
}
v := strings.TrimSpace(out)
if v == "" {
return "", fmt.Errorf("pkg latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return v, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"upgrade", "-n"}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("pkg listUpgrades: %w", err)
}
return parseUpgrades(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(ctx context.Context, ver1, ver2 string) (int, error) {
c := exec.CommandContext(ctx, "pkg", "version", "-t", ver1, ver2)
out, err := c.Output()
if err != nil {
return 0, fmt.Errorf("pkg version -t: %w", err)
}
switch strings.TrimSpace(string(out)) {
case "<":
return -1, nil
case ">":
return 1, nil
case "=":
return 0, nil
default:
return 0, fmt.Errorf("pkg version -t: unexpected output %q", string(out))
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := run(ctx, []string{"autoremove", "-y"}, o)
return err
}
func clean(ctx context.Context) error {
_, err := run(ctx, []string{"clean", "-y"}, snack.Options{})
return err
}
func fileList(ctx context.Context, pkg string) ([]string, error) {
out, err := run(ctx, []string{"info", "-l", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "exit status 70") {
return nil, fmt.Errorf("pkg fileList %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("pkg fileList: %w", err)
}
return parseFileList(out), nil
}
func owner(ctx context.Context, path string) (string, error) {
out, err := run(ctx, []string{"which", path}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "was not found") {
return "", fmt.Errorf("pkg owner %s: %w", path, snack.ErrNotFound)
}
return "", fmt.Errorf("pkg owner: %w", err)
}
return parseOwner(out), nil
}

41
pkg/capabilities_other.go Normal file
View File

@@ -0,0 +1,41 @@
//go:build !freebsd
package pkg
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
}

162
pkg/parse.go Normal file
View File

@@ -0,0 +1,162 @@
package pkg
import (
"strings"
"github.com/gogrlx/snack"
)
// parseQuery parses the output of `pkg query '%n\t%v\t%c'`.
func parseQuery(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 2 {
continue
}
p := snack.Package{
Name: parts[0],
Version: parts[1],
Installed: true,
}
if len(parts) == 3 {
p.Description = parts[2]
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseSearch parses the output of `pkg search <query>`.
// Format: "name-version Comment text"
func parseSearch(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Split on whitespace; first field is name-version
parts := strings.SplitN(line, " ", 2)
nameVer := parts[0]
name, ver := splitNameVersion(nameVer)
p := snack.Package{
Name: name,
Version: ver,
}
if len(parts) == 2 {
p.Description = strings.TrimSpace(parts[1])
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseInfo parses the output of `pkg info <pkg>`.
// Format is "Key: Value" lines.
func parseInfo(output string) *snack.Package {
pkg := &snack.Package{Installed: true}
for _, line := range strings.Split(output, "\n") {
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
switch key {
case "Name":
pkg.Name = val
case "Version":
pkg.Version = val
case "Comment":
pkg.Description = val
case "Arch":
pkg.Arch = val
}
}
if pkg.Name == "" {
return nil
}
return pkg
}
// parseUpgrades parses the output of `pkg upgrade -n`.
// Looks for lines like:
//
// Installing pkg-name: oldver -> newver
// Upgrading pkg-name: oldver -> newver
func parseUpgrades(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
// Look for "Upgrading" or "Reinstalling" lines with ->
if !strings.Contains(line, "->") {
continue
}
var nameVer string
if strings.HasPrefix(line, "Upgrading ") {
nameVer = strings.TrimPrefix(line, "Upgrading ")
} else if strings.HasPrefix(line, "Installing ") {
nameVer = strings.TrimPrefix(line, "Installing ")
} else if strings.HasPrefix(line, "Reinstalling ") {
nameVer = strings.TrimPrefix(line, "Reinstalling ")
} else {
continue
}
// "name: oldver -> newver"
colonIdx := strings.Index(nameVer, ":")
if colonIdx < 0 {
continue
}
name := strings.TrimSpace(nameVer[:colonIdx])
rest := strings.TrimSpace(nameVer[colonIdx+1:])
parts := strings.Fields(rest)
if len(parts) >= 3 && parts[1] == "->" {
pkgs = append(pkgs, snack.Package{
Name: name,
Version: parts[2],
Installed: true,
})
}
}
return pkgs
}
// parseFileList parses `pkg info -l <pkg>` output.
// Lines starting with "/" after the header are file paths.
func parseFileList(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
}
// parseOwner parses `pkg which <path>` output.
// Format: "/path was installed by package name-version"
func parseOwner(output string) string {
output = strings.TrimSpace(output)
if idx := strings.Index(output, "was installed by package "); idx != -1 {
remainder := output[idx+len("was installed by package "):]
name, _ := splitNameVersion(strings.TrimSpace(remainder))
return name
}
return output
}
// splitNameVersion splits "name-version" into name and version.
// FreeBSD pkg uses the last hyphen as separator (name can contain hyphens).
func splitNameVersion(s string) (string, string) {
idx := strings.LastIndex(s, "-")
if idx <= 0 {
return s, ""
}
return s[:idx], s[idx+1:]
}

View File

@@ -1,2 +1,87 @@
// Package pkg provides Go bindings for pkg(8) (FreeBSD package manager).
package pkg
import (
"context"
"github.com/gogrlx/snack"
)
// Pkg wraps the FreeBSD pkg(8) package manager CLI.
type Pkg struct {
snack.Locker
}
// New returns a new Pkg manager.
func New() *Pkg {
return &Pkg{}
}
// Name returns "pkg".
func (p *Pkg) Name() string { return "pkg" }
// Available reports whether pkg is present on the system.
func (p *Pkg) Available() bool { return available() }
// Install one or more packages.
func (p *Pkg) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (p *Pkg) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages with force (removing dependent packages).
func (p *Pkg) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages to their latest versions.
func (p *Pkg) Upgrade(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return upgrade(ctx, opts...)
}
// Update refreshes the package database.
func (p *Pkg) Update(ctx context.Context) error {
p.Lock()
defer p.Unlock()
return update(ctx)
}
// List returns all installed packages.
func (p *Pkg) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the repositories for packages matching the query.
func (p *Pkg) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (p *Pkg) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (p *Pkg) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Pkg)(nil)

148
pkg/pkg_freebsd.go Normal file
View File

@@ -0,0 +1,148 @@
//go:build freebsd
package pkg
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("pkg")
return err == nil
}
func run(ctx context.Context, args []string, opts snack.Options) (string, error) {
cmdName := "pkg"
cmdArgs := make([]string, 0, len(args)+2)
cmdArgs = append(cmdArgs, args...)
if opts.Sudo {
cmdArgs = append([]string{cmdName}, cmdArgs...)
cmdName = "sudo"
}
c := exec.CommandContext(ctx, cmdName, cmdArgs...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") || strings.Contains(se, "Insufficient privileges") {
return "", fmt.Errorf("pkg: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("pkg: %s: %w", strings.TrimSpace(se), err)
}
return stdout.String(), nil
}
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...)
args := append([]string{"install", "-y"}, formatTargets(pkgs)...)
_, err := run(ctx, args, o)
return err
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"delete", "-y"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, o)
return err
}
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"delete", "-y", "-f"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, o)
return err
}
func upgrade(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := run(ctx, []string{"upgrade", "-y"}, o)
return err
}
func update(ctx context.Context) error {
_, err := run(ctx, []string{"update"}, snack.Options{})
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"query", "%n\t%v\t%c"}, snack.Options{})
if err != nil {
return nil, fmt.Errorf("pkg list: %w", err)
}
return parseQuery(out), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
out, err := run(ctx, []string{"search", query}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("pkg search: %w", err)
}
return parseSearch(out), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "exit status 70") {
return nil, fmt.Errorf("pkg info %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("pkg info: %w", err)
}
p := parseInfo(out)
if p == nil {
return nil, fmt.Errorf("pkg info %s: %w", pkg, snack.ErrNotFound)
}
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pkg", "info", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
return false, nil
}
return false, fmt.Errorf("pkg isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"query", "%v", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "exit status 70") {
return "", fmt.Errorf("pkg version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("pkg version: %w", err)
}
v := strings.TrimSpace(out)
if v == "" {
return "", fmt.Errorf("pkg version %s: %w", pkg, snack.ErrNotInstalled)
}
return v, nil
}

51
pkg/pkg_other.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build !freebsd
package pkg
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}