mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
feat(pacman): implement Manager interface for pacman package manager
Adds full pacman wrapper with: - All Manager interface methods (Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version, Available) - Linux implementation using exec.CommandContext - Non-linux stubs returning ErrUnsupportedPlatform - Output parsing for -Q, -Ss, -Si formats - Options support: WithSudo, WithAssumeYes, WithDryRun, WithRoot - Unit tests for parsing and argument building
This commit is contained in:
@@ -1,2 +1,75 @@
|
||||
// Package pacman provides Go bindings for the pacman package manager (Arch Linux).
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Pacman wraps the pacman package manager CLI.
|
||||
type Pacman struct{}
|
||||
|
||||
// New returns a new Pacman manager.
|
||||
func New() *Pacman {
|
||||
return &Pacman{}
|
||||
}
|
||||
|
||||
// Name returns "pacman".
|
||||
func (p *Pacman) Name() string { return "pacman" }
|
||||
|
||||
// Available reports whether pacman is present on the system.
|
||||
func (p *Pacman) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (p *Pacman) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (p *Pacman) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including configuration files.
|
||||
func (p *Pacman) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages to their latest versions.
|
||||
func (p *Pacman) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the package database.
|
||||
func (p *Pacman) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (p *Pacman) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the repositories for packages matching the query.
|
||||
func (p *Pacman) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (p *Pacman) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (p *Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
|
||||
152
pacman/pacman_linux.go
Normal file
152
pacman/pacman_linux.go
Normal file
@@ -0,0 +1,152 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("pacman")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// buildArgs constructs the command name and argument list from the base args
|
||||
// and the provided options.
|
||||
func buildArgs(baseArgs []string, opts snack.Options) (string, []string) {
|
||||
cmd := "pacman"
|
||||
args := make([]string, 0, len(baseArgs)+4)
|
||||
|
||||
if opts.Root != "" {
|
||||
args = append(args, "-r", opts.Root)
|
||||
}
|
||||
args = append(args, baseArgs...)
|
||||
if opts.AssumeYes {
|
||||
args = append(args, "--noconfirm")
|
||||
}
|
||||
if opts.DryRun {
|
||||
args = append(args, "--print")
|
||||
}
|
||||
|
||||
if opts.Sudo {
|
||||
args = append([]string{cmd}, args...)
|
||||
cmd = "sudo"
|
||||
}
|
||||
return cmd, args
|
||||
}
|
||||
|
||||
func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, error) {
|
||||
cmd, args := buildArgs(baseArgs, opts)
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
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, "requires root") {
|
||||
return "", fmt.Errorf("pacman: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("pacman: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"-S", "--noconfirm"}, pkgs...)
|
||||
_, err := run(ctx, args, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"-R", "--noconfirm"}, pkgs...)
|
||||
_, err := run(ctx, args, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"-Rns", "--noconfirm"}, 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{"-Syu", "--noconfirm"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"-Sy"}, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-Q"}, snack.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pacman list: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-Ss", query}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("pacman search: %w", err)
|
||||
}
|
||||
return parseSearch(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-Si", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("pacman info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("pacman info: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("pacman info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("pacman isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"-Q", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("pacman version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("pacman version: %w", err)
|
||||
}
|
||||
parts := strings.Fields(strings.TrimSpace(out))
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("pacman version %s: unexpected output %q", pkg, out)
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
51
pacman/pacman_other.go
Normal file
51
pacman/pacman_other.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build !linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []string, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []string, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func purge(_ context.Context, _ []string, _ ...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
|
||||
}
|
||||
140
pacman/pacman_test.go
Normal file
140
pacman/pacman_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := `linux 6.7.4.arch1-1
|
||||
glibc 2.39-1
|
||||
bash 5.2.026-2
|
||||
`
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "linux" || pkgs[0].Version != "6.7.4.arch1-1" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := `core/linux 6.7.4.arch1-1 [installed]
|
||||
The Linux kernel and modules
|
||||
extra/linux-lts 6.6.14-1
|
||||
The LTS Linux kernel and modules
|
||||
`
|
||||
pkgs := parseSearch(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Repository != "core" || pkgs[0].Name != "linux" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected first package to be installed")
|
||||
}
|
||||
if pkgs[1].Installed {
|
||||
t.Error("expected second package to not be installed")
|
||||
}
|
||||
if pkgs[1].Description != "The LTS Linux kernel and modules" {
|
||||
t.Errorf("unexpected description: %q", pkgs[1].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Repository : core
|
||||
Name : linux
|
||||
Version : 6.7.4.arch1-1
|
||||
Description : The Linux kernel and modules
|
||||
Architecture : x86_64
|
||||
`
|
||||
pkg := parseInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "linux" {
|
||||
t.Errorf("expected name 'linux', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "6.7.4.arch1-1" {
|
||||
t.Errorf("unexpected version: %q", pkg.Version)
|
||||
}
|
||||
if pkg.Arch != "x86_64" {
|
||||
t.Errorf("unexpected arch: %q", pkg.Arch)
|
||||
}
|
||||
if pkg.Repository != "core" {
|
||||
t.Errorf("unexpected repo: %q", pkg.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base []string
|
||||
opts snack.Options
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with sudo",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Sudo: true},
|
||||
wantCmd: "sudo",
|
||||
wantArgs: []string{"pacman", "-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with root and noconfirm",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Root: "/mnt", AssumeYes: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{DryRun: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim", "--print"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, args := buildArgs(tt.base, tt.opts)
|
||||
if cmd != tt.wantCmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
|
||||
}
|
||||
if len(args) != len(tt.wantArgs) {
|
||||
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tt.wantArgs[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
p := New()
|
||||
if p.Name() != "pacman" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "pacman")
|
||||
}
|
||||
}
|
||||
105
pacman/parse.go
Normal file
105
pacman/parse.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseList parses the output of `pacman -Q` into packages.
|
||||
// Each line is "name version".
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseSearch parses the output of `pacman -Ss` into packages.
|
||||
// Format:
|
||||
//
|
||||
// repo/name version [installed]
|
||||
// Description text
|
||||
func parseSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
lines := strings.Split(output, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
// repo/name version [installed]
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
repoName := strings.SplitN(parts[0], "/", 2)
|
||||
pkg := snack.Package{
|
||||
Version: parts[1],
|
||||
}
|
||||
if len(repoName) == 2 {
|
||||
pkg.Repository = repoName[0]
|
||||
pkg.Name = repoName[1]
|
||||
} else {
|
||||
pkg.Name = repoName[0]
|
||||
}
|
||||
for _, p := range parts[2:] {
|
||||
if strings.Contains(p, "installed") {
|
||||
pkg.Installed = true
|
||||
}
|
||||
}
|
||||
// Next line is description
|
||||
if i+1 < len(lines) {
|
||||
desc := strings.TrimSpace(lines[i+1])
|
||||
if desc != "" {
|
||||
pkg.Description = desc
|
||||
i++
|
||||
}
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses the output of `pacman -Si` into a Package.
|
||||
// Format is "Key : Value" lines.
|
||||
func parseInfo(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
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 "Description":
|
||||
pkg.Description = val
|
||||
case "Architecture":
|
||||
pkg.Arch = val
|
||||
case "Repository":
|
||||
pkg.Repository = val
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
Reference in New Issue
Block a user