diff --git a/cmd/snack/main.go b/cmd/snack/main.go index 4b1aea1..72a6a61 100644 --- a/cmd/snack/main.go +++ b/cmd/snack/main.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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) + }, + } +} diff --git a/detect/detect.go b/detect/detect.go index 3862d57..0d9736d 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -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) diff --git a/detect/detect_freebsd.go b/detect/detect_freebsd.go new file mode 100644 index 0000000..7c1a8c1 --- /dev/null +++ b/detect/detect_freebsd.go @@ -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() +} diff --git a/detect/detect_linux.go b/detect/detect_linux.go new file mode 100644 index 0000000..863d813 --- /dev/null +++ b/detect/detect_linux.go @@ -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() +} diff --git a/detect/detect_openbsd.go b/detect/detect_openbsd.go new file mode 100644 index 0000000..982303a --- /dev/null +++ b/detect/detect_openbsd.go @@ -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 +} diff --git a/detect/detect_test.go b/detect/detect_test.go new file mode 100644 index 0000000..fa7242a --- /dev/null +++ b/detect/detect_test.go @@ -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() +} diff --git a/go.mod b/go.mod index 4d03529..61f7b23 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index a6ee3e0..e9588ab 100644 --- a/go.sum +++ b/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= diff --git a/pkg/capabilities.go b/pkg/capabilities.go new file mode 100644 index 0000000..050f7a7 --- /dev/null +++ b/pkg/capabilities.go @@ -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) +} diff --git a/pkg/capabilities_freebsd.go b/pkg/capabilities_freebsd.go new file mode 100644 index 0000000..178a12c --- /dev/null +++ b/pkg/capabilities_freebsd.go @@ -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 +} diff --git a/pkg/capabilities_other.go b/pkg/capabilities_other.go new file mode 100644 index 0000000..f688763 --- /dev/null +++ b/pkg/capabilities_other.go @@ -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 +} diff --git a/pkg/parse.go b/pkg/parse.go new file mode 100644 index 0000000..11c6a10 --- /dev/null +++ b/pkg/parse.go @@ -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 `. +// 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 `. +// 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 ` 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 ` 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:] +} diff --git a/pkg/pkg.go b/pkg/pkg.go index 6039075..d25c8e3 100644 --- a/pkg/pkg.go +++ b/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) diff --git a/pkg/pkg_freebsd.go b/pkg/pkg_freebsd.go new file mode 100644 index 0000000..8233c60 --- /dev/null +++ b/pkg/pkg_freebsd.go @@ -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 +} diff --git a/pkg/pkg_other.go b/pkg/pkg_other.go new file mode 100644 index 0000000..986e6d1 --- /dev/null +++ b/pkg/pkg_other.go @@ -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 +}