feat(platform): add PackageManager interface and Pacman implementation
This commit is contained in:
74
internal/platform/pacman.go
Normal file
74
internal/platform/pacman.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecRunner runs commands via os/exec. This is the production Runner.
|
||||||
|
type ExecRunner struct{}
|
||||||
|
|
||||||
|
func (ExecRunner) Run(ctx context.Context, name string, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pacman implements PackageManager via `pacman` on Arch.
|
||||||
|
type Pacman struct {
|
||||||
|
Runner Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPacman(r Runner) *Pacman {
|
||||||
|
if r == nil {
|
||||||
|
r = ExecRunner{}
|
||||||
|
}
|
||||||
|
return &Pacman{Runner: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Pacman) Name() string { return "pacman" }
|
||||||
|
|
||||||
|
// IsInstalled returns true when `pacman -Q <pkg>` succeeds.
|
||||||
|
func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||||
|
if pkg == "" {
|
||||||
|
return false, errors.New("empty package name")
|
||||||
|
}
|
||||||
|
_, err := p.Runner.Output(ctx, "pacman", "-Q", pkg)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// pacman -Q exits 1 when not installed; we treat any error as "not installed"
|
||||||
|
// because false-positives on transient errors here are harmless (Install
|
||||||
|
// is idempotent via --needed).
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install runs `sudo pacman -S --needed --noconfirm <pkgs...>`.
|
||||||
|
func (p Pacman) Install(ctx context.Context, pkgs []string) error {
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...)
|
||||||
|
return p.Runner.Run(ctx, "sudo", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update runs `sudo pacman -Sy`.
|
||||||
|
func (p Pacman) Update(ctx context.Context) error {
|
||||||
|
return p.Runner.Run(ctx, "sudo", "pacman", "-Sy")
|
||||||
|
}
|
||||||
87
internal/platform/pacman_test.go
Normal file
87
internal/platform/pacman_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outputCalls [][]string
|
||||||
|
outputErr error
|
||||||
|
output []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outputCalls = append(f.outputCalls, append([]string{name}, args...))
|
||||||
|
return f.output, f.outputErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
|
||||||
|
r := &fakeRunner{}
|
||||||
|
p := NewPacman(r)
|
||||||
|
if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) != 1 {
|
||||||
|
t.Fatalf("run calls = %d", len(r.runCalls))
|
||||||
|
}
|
||||||
|
want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"}
|
||||||
|
if !reflect.DeepEqual(r.runCalls[0], want) {
|
||||||
|
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanInstallEmptyIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRunner{}
|
||||||
|
p := NewPacman(r)
|
||||||
|
if err := p.Install(context.Background(), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) != 0 {
|
||||||
|
t.Fatalf("expected no calls, got %v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanIsInstalledTrue(t *testing.T) {
|
||||||
|
r := &fakeRunner{output: []byte("tio 3.2-1")}
|
||||||
|
p := NewPacman(r)
|
||||||
|
ok, err := p.IsInstalled(context.Background(), "tio")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("want installed=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanIsInstalledFalseOnError(t *testing.T) {
|
||||||
|
r := &fakeRunner{outputErr: errors.New("not found")}
|
||||||
|
p := NewPacman(r)
|
||||||
|
ok, err := p.IsInstalled(context.Background(), "ghost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("want installed=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanUpdate(t *testing.T) {
|
||||||
|
r := &fakeRunner{}
|
||||||
|
p := NewPacman(r)
|
||||||
|
if err := p.Update(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"sudo", "pacman", "-Sy"}
|
||||||
|
if !reflect.DeepEqual(r.runCalls[0], want) {
|
||||||
|
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/platform/pkgmgr.go
Normal file
19
internal/platform/pkgmgr.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// PackageManager abstracts OS-level package operations so later plans
|
||||||
|
// can add Debian/Fedora without touching modules.
|
||||||
|
type PackageManager interface {
|
||||||
|
Name() string
|
||||||
|
IsInstalled(ctx context.Context, pkg string) (bool, error)
|
||||||
|
Install(ctx context.Context, pkgs []string) error
|
||||||
|
Update(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner executes a command with arguments. Injected into package managers
|
||||||
|
// so tests can stub shell-outs.
|
||||||
|
type Runner interface {
|
||||||
|
Run(ctx context.Context, name string, args ...string) error
|
||||||
|
Output(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user