package installer import ( "bytes" "context" "fmt" "io" "os" "os/exec" "strings" "gitgud.io/mike/mpv-manager/pkg/keyring" "gitgud.io/mike/mpv-manager/pkg/log" ) // CommandRunner implements CommandExecutor interface var _ CommandExecutor = (*CommandRunner)(nil) type CommandRunner struct { outputChan chan<- string errorChan chan<- error ctx context.Context // Context for cancellation support } // NewCommandRunner creates a new command runner with output and error channels func NewCommandRunner(outputChan chan<- string, errorChan chan<- error) *CommandRunner { return &CommandRunner{ outputChan: outputChan, errorChan: errorChan, ctx: context.Background(), // Default to background context } } // NewCommandRunnerWithContext creates a new command runner with a specific context for cancellation func NewCommandRunnerWithContext(ctx context.Context, outputChan chan<- string, errorChan chan<- error) *CommandRunner { return &CommandRunner{ outputChan: outputChan, errorChan: errorChan, ctx: ctx, } } // Context returns the command runner's context func (cr *CommandRunner) Context() context.Context { return cr.ctx } // SetContext sets the context for the command runner func (cr *CommandRunner) SetContext(ctx context.Context) { cr.ctx = ctx } func (cr *CommandRunner) RunCommand(name string, args ...string) error { cmd := exec.CommandContext(cr.ctx, name, args...) return cr.RunCommandWithOutput(cmd) } func (cr *CommandRunner) RunCommandWithOutput(cmd *exec.Cmd) error { cmdStr := strings.Join(append([]string{cmd.Path}, cmd.Args[1:]...), " ") log.Command(cmdStr) if cr.outputChan != nil { cr.outputChan <- fmt.Sprintf("Running: %s", cmdStr) cr.outputChan <- strings.Repeat("─", 50) } var stdoutBuf, stderrBuf bytes.Buffer multiWriter := io.MultiWriter(&stdoutBuf, &stderrBuf) if cr.outputChan != nil { cmd.Stdout = io.MultiWriter(multiWriter, &outputCapture{outputChan: cr.outputChan}) cmd.Stderr = io.MultiWriter(multiWriter, &outputCapture{outputChan: cr.outputChan, isError: true}) } else { cmd.Stdout = multiWriter cmd.Stderr = multiWriter } if cmd.Stdin == nil { cmd.Stdin = os.Stdin } err := cmd.Run() output := stdoutBuf.String() + stderrBuf.String() if output != "" { log.Output(output) } if err != nil { log.Error(fmt.Sprintf("Command failed: %s - Error: %s", cmdStr, err.Error())) if cr.errorChan != nil { cr.errorChan <- err } if cr.outputChan != nil { cr.outputChan <- fmt.Sprintf("Error: %s", err.Error()) } return err } log.Info("Command completed successfully") return nil } type outputCapture struct { outputChan chan<- string isError bool } func (oc *outputCapture) Write(p []byte) (n int, err error) { lines := strings.Split(strings.TrimSpace(string(p)), "\n") for _, line := range lines { if line != "" { if oc.isError { log.ErrorOutput(line) } else { log.Output(line) } if oc.outputChan != nil { oc.outputChan <- line } } } return len(p), nil } func (cr *CommandRunner) RunShellCommand(script string) error { // Use bash for Linux/macOS, fall back to sh on Windows shell := "/bin/bash" if _, err := os.Stat(shell); os.IsNotExist(err) { shell = "sh" } cmd := exec.CommandContext(cr.ctx, shell, "-c", script) return cr.RunCommandWithOutput(cmd) } func (cr *CommandRunner) RunCommands(commands []string) error { for i, cmdStr := range commands { // Check for cancellation between commands select { case <-cr.ctx.Done(): return fmt.Errorf("command execution cancelled: %w", cr.ctx.Err()) default: } log.Separator(fmt.Sprintf("Command %d", i+1)) if err := cr.RunShellCommand(cmdStr); err != nil { return err } } return nil } // IsCancelled returns true if the context has been cancelled func (cr *CommandRunner) IsCancelled() bool { select { case <-cr.ctx.Done(): return true default: return false } } // CheckCancelled returns an error if the context has been cancelled func (cr *CommandRunner) CheckCancelled() error { select { case <-cr.ctx.Done(): return fmt.Errorf("operation cancelled: %w", cr.ctx.Err()) default: return nil } } // RunSudoCommand runs a command with sudo, using keyring password if available func (cr *CommandRunner) RunSudoCommand(name string, args ...string) error { // Already root - no sudo needed if os.Geteuid() == 0 { log.Debug("RunSudoCommand: Already running as root, no sudo needed") return cr.RunCommand(name, args...) } // Try to get password from keyring kr, err := keyring.Open() if err != nil { log.Debug(fmt.Sprintf("RunSudoCommand: Keyring not available: %v, falling back to regular sudo", err)) // No keyring available - fall back to regular sudo return cr.RunCommand("sudo", append([]string{name}, args...)...) } password, err := kr.GetPassword() if err != nil { log.Debug("RunSudoCommand: No password stored in keyring, falling back to regular sudo") // No password stored - fall back to regular sudo return cr.RunCommand("sudo", append([]string{name}, args...)...) } log.Debug("RunSudoCommand: Using password from keyring") // Validate and cache sudo credentials if err := cr.cacheSudoCredentials(password); err != nil { log.Error(fmt.Sprintf("RunSudoCommand: Sudo authentication failed: %v", err)) return fmt.Errorf("sudo authentication failed: %w", err) } log.Debug("RunSudoCommand: Sudo credentials cached, running command") // Run command with cached sudo return cr.RunCommand("sudo", append([]string{name}, args...)...) } // cacheSudoCredentials validates password and refreshes sudo timestamp func (cr *CommandRunner) cacheSudoCredentials(password string) error { log.Debug("cacheSudoCredentials: Validating sudo password") cmd := exec.CommandContext(cr.ctx, "sudo", "-S", "-v") cmd.Stdin = bytes.NewBufferString(password + "\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { log.Debug(fmt.Sprintf("cacheSudoCredentials: Validation failed - stderr: %s", stderr.String())) return fmt.Errorf("invalid password: %s", stderr.String()) } log.Debug("cacheSudoCredentials: Password validated, sudo timestamp refreshed") return nil } // RunSudoCommands runs multiple commands with sudo authentication func (cr *CommandRunner) RunSudoCommands(commands []string) error { log.Debug(fmt.Sprintf("RunSudoCommands: Running %d commands with sudo", len(commands))) if os.Geteuid() == 0 { log.Debug("RunSudoCommands: Already running as root") // Already root, run commands directly for _, cmdStr := range commands { if err := cr.RunShellCommand(cmdStr); err != nil { return err } } return nil } // Get password from keyring kr, err := keyring.Open() if err != nil { log.Error(fmt.Sprintf("RunSudoCommands: Keyring not available: %v", err)) return fmt.Errorf("no keyring available: %w", err) } password, err := kr.GetPassword() if err != nil { log.Error(fmt.Sprintf("RunSudoCommands: No password in keyring: %v", err)) return fmt.Errorf("no sudo password available: %w", err) } log.Debug("RunSudoCommands: Got password from keyring, authenticating") // Authenticate once if err := cr.cacheSudoCredentials(password); err != nil { return err } log.Debug("RunSudoCommands: Authenticated, running commands") // Run all commands (sudo timestamp is cached) for i, cmdStr := range commands { select { case <-cr.ctx.Done(): return fmt.Errorf("cancelled: %w", cr.ctx.Err()) default: } log.Separator(fmt.Sprintf("Command %d", i+1)) if err := cr.RunShellCommand("sudo " + cmdStr); err != nil { return err } } return nil }