diff --git a/README.md b/README.md index c876545..513a470 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ I/O take a `context.Context` for cancellation and timeouts. - **Identity:** `Name`, `Format`, `Path` - **Privilege:** `SetCmdWrapper`, `UseSudoWhenNeeded` +- **Prompts:** `AssumeYes` (answer confirmation prompts automatically) - **I/O:** `SetIO` (override stdin/stdout/stderr of spawned commands) - **Repos:** `AddRepo`, `AddRepoURL`, `RemoveRepo`, `GetRepo`, `ListRepos` - **Keys:** `AddRepoKey`, `AddRepoKeyFile`, `AddRepoKeyURL` @@ -130,6 +131,16 @@ Two mechanisms cover opposite requirements: A custom command wrapper (for example `[]string{"sudo", "-n"}`) can be supplied through `SetCmdWrapper`. +## Unattended operation + +By default the managers run their tools interactively, so a mutating operation +prompts for confirmation and aborts when no terminal is attached. `AssumeYes` +enables unattended mode: each manager injects its own non-interactive +confirmation flag (`-y` for `dnf`/`yum`/`apt`, `--noconfirm` for `pacman`/AUR +helpers, `--non-interactive` for `zypper`) into `Install`, `Remove`, `Upgrade`, +`InstallFile`, and `UpgradeAll`. A flag supplied explicitly through a method's +`args` is not duplicated. + ## Testing ```bash diff --git a/apt-get.go b/apt-get.go index 332f9b2..47058f2 100644 --- a/apt-get.go +++ b/apt-get.go @@ -43,12 +43,12 @@ func (p *AptGet) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *AptGet) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "install"), packages...)...) } // Remove removes packages. func (p *AptGet) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "remove"), packages...)...) } // Upgrade upgrades the named packages. @@ -63,7 +63,7 @@ func (p *AptGet) InstallFile(ctx context.Context, args []string, packages ...str // UpgradeAll upgrades all packages with available updates. func (p *AptGet) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "upgrade")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "-y"), "upgrade")...) } // Clean removes the local package cache. diff --git a/apt.go b/apt.go index e160026..37981e3 100644 --- a/apt.go +++ b/apt.go @@ -42,12 +42,12 @@ func (p *Apt) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *Apt) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "install"), packages...)...) } // Remove removes packages. func (p *Apt) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "remove"), packages...)...) } // Upgrade upgrades the named packages. @@ -62,7 +62,7 @@ func (p *Apt) InstallFile(ctx context.Context, args []string, packages ...string // UpgradeAll upgrades all packages with available updates. func (p *Apt) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "upgrade")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "-y"), "upgrade")...) } // Clean removes the local package cache. diff --git a/aur.go b/aur.go index da65e70..2942a4d 100644 --- a/aur.go +++ b/aur.go @@ -55,12 +55,12 @@ func (p *aurBase) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories and the AUR. func (p *aurBase) Install(ctx context.Context, args []string, packages ...string) error { - return p.aurExec(ctx, append(joinArgs(args, "-S"), packages...)...) + return p.aurExec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-S"), packages...)...) } // Remove removes packages. func (p *aurBase) Remove(ctx context.Context, args []string, packages ...string) error { - return p.aurExec(ctx, append(joinArgs(args, "-R"), packages...)...) + return p.aurExec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-R"), packages...)...) } // Upgrade upgrades the named packages. @@ -70,12 +70,12 @@ func (p *aurBase) Upgrade(ctx context.Context, args []string, packages ...string // InstallFile installs a package from a local file. func (p *aurBase) InstallFile(ctx context.Context, args []string, packages ...string) error { - return p.aurExec(ctx, append(joinArgs(args, "-U"), packages...)...) + return p.aurExec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-U"), packages...)...) } // UpgradeAll upgrades all packages with available updates, including the AUR. func (p *aurBase) UpgradeAll(ctx context.Context, args []string) error { - return p.aurExec(ctx, joinArgs(args, "-Syu")...) + return p.aurExec(ctx, joinArgs(p.confirmArgs(args, "--noconfirm"), "-Syu")...) } // Search searches the repositories and the AUR for packages matching query. The diff --git a/dnf.go b/dnf.go index 6d83989..3696ff2 100644 --- a/dnf.go +++ b/dnf.go @@ -40,17 +40,17 @@ func (p *Dnf) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *Dnf) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "install"), packages...)...) } // Remove removes packages. func (p *Dnf) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "remove"), packages...)...) } // Upgrade upgrades the named packages. func (p *Dnf) Upgrade(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "upgrade"), packages...)...) } // InstallFile installs a package from a local file. @@ -60,7 +60,7 @@ func (p *Dnf) InstallFile(ctx context.Context, args []string, packages ...string // UpgradeAll upgrades all packages with available updates. func (p *Dnf) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "upgrade")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "-y"), "upgrade")...) } // Clean removes cached repository metadata and packages. diff --git a/helpers.go b/helpers.go index 31a7704..f7c8414 100644 --- a/helpers.go +++ b/helpers.go @@ -130,6 +130,17 @@ func joinArgs(args []string, extra ...string) []string { return out } +// containsArg reports whether args already contains flag, used to avoid +// duplicating a confirmation flag the caller supplied explicitly. +func containsArg(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + // writeRepoFile writes config to filePath, creating parent directories. func writeRepoFile(filePath, config string) error { if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { diff --git a/manager_test.go b/manager_test.go index 099dfdf..21e28d3 100644 --- a/manager_test.go +++ b/manager_test.go @@ -79,6 +79,71 @@ func TestJoinArgsDoesNotMutate(t *testing.T) { assert.Equal(t, []string{"-y", "install", "vim"}, got) } +func TestConfirmArgs(t *testing.T) { + var b baseManager + + // Off by default: args pass through untouched. + assert.Equal(t, []string{"install"}, b.confirmArgs([]string{"install"}, "-y")) + + b.AssumeYes() + + // On: the flag is prepended so it precedes the subcommand. + assert.Equal(t, []string{"-y", "install"}, b.confirmArgs([]string{"install"}, "-y")) + + // A flag the caller already supplied is not duplicated. + assert.Equal(t, []string{"-y", "install"}, b.confirmArgs([]string{"-y", "install"}, "-y")) + + // Multiple flags are all injected, preserving order. + assert.Equal(t, []string{"-a", "-b", "-S"}, b.confirmArgs([]string{"-S"}, "-a", "-b")) + + // The caller's slice is not mutated, even with spare capacity. + in := make([]string, 1, 8) + in[0] = "install" + _ = b.confirmArgs(in, "-y") + assert.Equal(t, []string{"install"}, in, "confirmArgs mutated its input") +} + +// TestAssumeYesInjectsConfirmFlag verifies that, with AssumeYes enabled, each +// manager places its own non-interactive confirmation flag ahead of the +// subcommand so an unattended install does not stall on a prompt. A fake binary +// on PATH records the arguments it receives. +func TestAssumeYesInjectsConfirmFlag(t *testing.T) { + cases := []struct { + name string + bin string + newM func() Manager + want []string + }{ + {"dnf", "dnf", func() Manager { return &Dnf{} }, []string{"-y", "install", "vim"}}, + {"yum", "yum", func() Manager { return &Yum{} }, []string{"-y", "install", "vim"}}, + {"apt", "apt", func() Manager { return &Apt{} }, []string{"-y", "install", "vim"}}, + {"apt-get", "apt-get", func() Manager { return &AptGet{} }, []string{"-y", "install", "vim"}}, + {"pacman", "pacman", func() Manager { return &Pacman{} }, []string{"--noconfirm", "-S", "vim"}}, + // zypper's --non-interactive is a global option that must precede the + // subcommand, which prepending guarantees. + {"zypper", "zypper", func() Manager { return &Zypper{} }, []string{"--non-interactive", "install", "vim"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "args") + // The fake binary records each argument on its own line, then exits 0. + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + marker + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, tc.bin), []byte(script), 0755)) + t.Setenv("PATH", dir) + + m := tc.newM() + m.AssumeYes() + require.NoError(t, m.Install(context.Background(), nil, "vim")) + + data, err := os.ReadFile(marker) + require.NoError(t, err, "manager did not invoke the binary") + assert.Equal(t, tc.want, strings.Fields(string(data))) + }) + } +} + func TestParseVersionList(t *testing.T) { in := "git := 1:2.39.0\nfoo := 0:1.0\nmalformed line\n\n bar := 2.0 \n" got, err := parseVersionList(strings.NewReader(in), " := ", true) diff --git a/managers.go b/managers.go index 48bc876..5ec9e9e 100644 --- a/managers.go +++ b/managers.go @@ -32,6 +32,11 @@ type Manager interface { // root. It is a no-op as root, and managers that must not run as root // (brew, yay, paru) bypass the wrapper for their own command. UseSudoWhenNeeded() + // AssumeYes enables unattended operation so state-changing commands answer + // their confirmation prompt automatically. It is required when the manager + // runs without a controlling terminal, where an unanswered prompt aborts + // the operation. + AssumeYes() // SetIO overrides the stdin, stdout, and stderr used by spawned commands. // Passing nil for a stream restores its os default. SetIO(stdin io.Reader, stdout, stderr io.Writer) @@ -102,10 +107,11 @@ func GetSystemManager() Manager { // baseManager holds state and helpers shared by every manager implementation. type baseManager struct { - wrapper []string - stdin io.Reader - stdout io.Writer - stderr io.Writer + wrapper []string + assumeYes bool + stdin io.Reader + stdout io.Writer + stderr io.Writer } // SetCmdWrapper sets a command wrapper, for example []string{"sudo"}. @@ -120,6 +126,33 @@ func (p *baseManager) SetIO(stdin io.Reader, stdout, stderr io.Writer) { p.stderr = stderr } +// AssumeYes enables unattended operation so state-changing commands answer +// their confirmation prompt automatically. It is an opt-in toggle honored by +// each manager's Install, Remove, Upgrade, InstallFile, and UpgradeAll methods. +func (p *baseManager) AssumeYes() { + p.assumeYes = true +} + +// confirmArgs prepends the manager's non-interactive confirmation flags to args +// when AssumeYes is enabled, so an unattended run does not stall on a prompt. It +// returns args unchanged when the toggle is off, and never duplicates a flag the +// caller already supplied. +func (p *baseManager) confirmArgs(args []string, flags ...string) []string { + if !p.assumeYes { + return args + } + var prefix []string + for _, f := range flags { + if !containsArg(args, f) { + prefix = append(prefix, f) + } + } + if len(prefix) == 0 { + return args + } + return append(prefix, args...) +} + // stdinOrDefault returns the configured stdin, or os.Stdin when unset. func (p *baseManager) stdinOrDefault() io.Reader { if p.stdin != nil { diff --git a/pacman.go b/pacman.go index 5e2964f..8a2611a 100644 --- a/pacman.go +++ b/pacman.go @@ -128,12 +128,12 @@ func (p *Pacman) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *Pacman) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "-S"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-S"), packages...)...) } // Remove removes packages. func (p *Pacman) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "-R"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-R"), packages...)...) } // Upgrade upgrades the named packages. @@ -143,12 +143,12 @@ func (p *Pacman) Upgrade(ctx context.Context, args []string, packages ...string) // InstallFile installs a package from a local file. func (p *Pacman) InstallFile(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "-U"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--noconfirm"), "-U"), packages...)...) } // UpgradeAll upgrades all packages with available updates. func (p *Pacman) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "-Syu")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "--noconfirm"), "-Syu")...) } // Clean removes cached packages from the package cache. diff --git a/yum.go b/yum.go index 02c89b0..16f9f00 100644 --- a/yum.go +++ b/yum.go @@ -40,17 +40,17 @@ func (p *Yum) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *Yum) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "install"), packages...)...) } // Remove removes packages. func (p *Yum) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "remove"), packages...)...) } // Upgrade upgrades the named packages. func (p *Yum) Upgrade(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "-y"), "upgrade"), packages...)...) } // InstallFile installs a package from a local file. @@ -60,7 +60,7 @@ func (p *Yum) InstallFile(ctx context.Context, args []string, packages ...string // UpgradeAll upgrades all packages with available updates. func (p *Yum) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "upgrade")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "-y"), "upgrade")...) } // Clean removes cached repository metadata and packages. diff --git a/zypper.go b/zypper.go index 9eab163..1c4f546 100644 --- a/zypper.go +++ b/zypper.go @@ -43,17 +43,17 @@ func (p *Zypper) Sync(ctx context.Context, args []string) error { // Install installs packages from the repositories. func (p *Zypper) Install(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--non-interactive"), "install"), packages...)...) } // Remove removes packages. func (p *Zypper) Remove(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--non-interactive"), "remove"), packages...)...) } // Upgrade upgrades the named packages. func (p *Zypper) Upgrade(ctx context.Context, args []string, packages ...string) error { - return p.exec(ctx, append(joinArgs(args, "update"), packages...)...) + return p.exec(ctx, append(joinArgs(p.confirmArgs(args, "--non-interactive"), "update"), packages...)...) } // InstallFile installs a package from a local file. @@ -63,7 +63,7 @@ func (p *Zypper) InstallFile(ctx context.Context, args []string, packages ...str // UpgradeAll upgrades all packages with available updates. func (p *Zypper) UpgradeAll(ctx context.Context, args []string) error { - return p.exec(ctx, joinArgs(args, "update")...) + return p.exec(ctx, joinArgs(p.confirmArgs(args, "--non-interactive"), "update")...) } // Clean removes cached repository metadata and packages.