From fcc24b99b25e077dd7627158f54e1fde679c9258 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 08:35:46 +0200 Subject: [PATCH] feat(platform): add AURHelper with yay bootstrap and install --- internal/platform/aur.go | 57 ++++++++++++++++++++++ internal/platform/aur_test.go | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 internal/platform/aur.go create mode 100644 internal/platform/aur_test.go diff --git a/internal/platform/aur.go b/internal/platform/aur.go new file mode 100644 index 0000000..fc337ad --- /dev/null +++ b/internal/platform/aur.go @@ -0,0 +1,57 @@ +package platform + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// AURHelper abstracts an AUR front-end (currently only yay). +type AURHelper interface { + EnsureInstalled(ctx context.Context) error + Install(ctx context.Context, pkgs []string) error +} + +type Yay struct { + Runner Runner + pm *Pacman +} + +func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} } + +func (y *Yay) EnsureInstalled(ctx context.Context) error { + ok, _ := y.pm.IsInstalled(ctx, "yay") + if ok { + return nil + } + // Bootstrap: install build deps, clone AUR yay, makepkg -si. + if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil { + return err + } + tmp, err := os.MkdirTemp("", "archduino-yay-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + clone := filepath.Join(tmp, "yay") + if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil { + return err + } + // `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does + // not have a per-command cwd without exec.Cmd; our Runner interface does + // not expose that, so we invoke via sh -c with a pushd-style prefix. + return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm") +} + +func (y *Yay) Install(ctx context.Context, pkgs []string) error { + if len(pkgs) == 0 { + return nil + } + args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...) + return y.Runner.Run(ctx, "yay", args...) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/platform/aur_test.go b/internal/platform/aur_test.go new file mode 100644 index 0000000..0726211 --- /dev/null +++ b/internal/platform/aur_test.go @@ -0,0 +1,89 @@ +package platform + +import ( + "context" + "strings" + "testing" +) + +type aurFakeRunner struct { + runCalls [][]string + outCalls [][]string + // yayInstalled toggles what `pacman -Q yay` returns. + yayInstalled bool +} + +func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} + +func (f *aurFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" && args[1] == "yay" { + if f.yayInstalled { + return []byte("yay 12.0-1\n"), nil + } + return nil, &fakeExitErr{} + } + return nil, nil +} + +type fakeExitErr struct{} + +func (fakeExitErr) Error() string { return "exit 1" } + +func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 0 { + t.Fatalf("no installs expected, got %v", r.runCalls) + } +} + +func TestAUREnsureInstalledBootstrapsYay(t *testing.T) { + r := &aurFakeRunner{yayInstalled: false} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { + t.Fatal(err) + } + // Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir. + sawPacman := false + sawClone := false + sawMakepkg := false + for _, c := range r.runCalls { + if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" { + sawPacman = true + } + if len(c) >= 2 && c[0] == "git" && c[1] == "clone" { + sawClone = true + } + if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") { + sawMakepkg = true + } + } + if !sawPacman || !sawClone || !sawMakepkg { + t.Fatalf("missing bootstrap step; calls=%v", r.runCalls) + } +} + +func TestAURInstall(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil { + t.Fatal(err) + } + if len(r.runCalls) == 0 { + t.Fatal("expected yay install call") + } + c := r.runCalls[len(r.runCalls)-1] + want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"} + for i, w := range want { + if i >= len(c) || c[i] != w { + t.Fatalf("call = %v, want %v", c, want) + } + } +}