mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Merge pull request #10 from gogrlx/cd/detect-cli
feat: implement detect package and snack CLI
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
18
detect/detect_freebsd.go
Normal 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
31
detect/detect_linux.go
Normal 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
13
detect/detect_openbsd.go
Normal 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
22
detect/detect_test.go
Normal 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
28
go.mod
@@ -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
62
go.sum
@@ -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
58
pkg/capabilities.go
Normal 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
102
pkg/capabilities_freebsd.go
Normal 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
41
pkg/capabilities_other.go
Normal 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
162
pkg/parse.go
Normal 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:]
|
||||
}
|
||||
85
pkg/pkg.go
85
pkg/pkg.go
@@ -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
148
pkg/pkg_freebsd.go
Normal 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
51
pkg/pkg_other.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user