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:
parent
03f319e014
commit
3f7836c681
11 changed files with 150 additions and 30 deletions
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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
6
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.
|
||||
|
|
|
|||
8
aur.go
8
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
|
||||
|
|
|
|||
8
dnf.go
8
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.
|
||||
|
|
|
|||
11
helpers.go
11
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
41
managers.go
41
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 {
|
||||
|
|
|
|||
|
|
@ -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
8
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue