Add AssumeYes opt-in for unattended installs

Interactive tools prompt for confirmation and abort when no terminal is
attached, so unattended callers stall on 'Is this ok [y/N]:'. Add an
AssumeYes toggle, mirroring UseSudoWhenNeeded, that injects each manager's
non-interactive confirmation flag (-y, --noconfirm, --non-interactive) into
Install, Remove, Upgrade, InstallFile, and UpgradeAll. A flag already
supplied by the caller is not duplicated.
This commit is contained in:
James Coleman 2026-07-20 10:13:25 -05:00
parent 03f319e014
commit 3f7836c681
11 changed files with 150 additions and 30 deletions

View file

@ -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

View file

@ -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.

6
apt.go
View file

@ -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.

8
aur.go
View file

@ -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

8
dnf.go
View file

@ -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.

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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.

8
yum.go
View file

@ -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.

View file

@ -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.