mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
287 lines
7.8 KiB
Go
Executable File
287 lines
7.8 KiB
Go
Executable File
package installer
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/chenasraf/sofmani/appconfig"
|
|
"github.com/chenasraf/sofmani/logger"
|
|
"github.com/chenasraf/sofmani/utils"
|
|
)
|
|
|
|
// BrewInstaller is an installer for Homebrew packages.
|
|
type BrewInstaller struct {
|
|
InstallerBase
|
|
// Config is the application configuration.
|
|
Config *appconfig.AppConfig
|
|
// Info is the installer data.
|
|
Info *appconfig.InstallerData
|
|
}
|
|
|
|
// BrewOpts represents options for the BrewInstaller.
|
|
type BrewOpts struct {
|
|
// Tap is the Homebrew tap to use for the package.
|
|
Tap *string
|
|
// Cask installs the formula as a cask instead of a regular package.
|
|
Cask *bool
|
|
// Flags is a string of additional flags to pass to the brew command.
|
|
Flags *string
|
|
// InstallFlags is a string of additional flags to pass only during install.
|
|
InstallFlags *string
|
|
// UpdateFlags is a string of additional flags to pass only during update.
|
|
UpdateFlags *string
|
|
}
|
|
|
|
// Validate validates the installer configuration.
|
|
func (i *BrewInstaller) Validate() []ValidationError {
|
|
errors := i.BaseValidate()
|
|
info := i.GetData()
|
|
opts := i.GetOpts()
|
|
if opts.Tap != nil {
|
|
if !strings.Contains(*opts.Tap, "/") || len(*opts.Tap) < 3 {
|
|
errors = append(errors, ValidationError{FieldName: "tap", Message: validationInvalidFormat(), InstallerName: *info.Name})
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
// Install implements IInstaller.
|
|
func (i *BrewInstaller) Install() error {
|
|
name := i.GetFullName()
|
|
opts := i.GetOpts()
|
|
i.handleBrewRepoUpdate()
|
|
cmd := "brew install"
|
|
if i.IsVerbose() {
|
|
cmd += " --verbose"
|
|
}
|
|
if i.IsCask() {
|
|
cmd += " --cask"
|
|
}
|
|
if opts.InstallFlags != nil {
|
|
cmd += " " + *opts.InstallFlags
|
|
} else if opts.Flags != nil {
|
|
cmd += " " + *opts.Flags
|
|
}
|
|
err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name))
|
|
i.markBrewRepoUpdated()
|
|
return err
|
|
}
|
|
|
|
// Update implements IInstaller.
|
|
func (i *BrewInstaller) Update() error {
|
|
name := i.GetFullName()
|
|
opts := i.GetOpts()
|
|
i.handleBrewRepoUpdate()
|
|
cmd := "brew upgrade"
|
|
if i.IsVerbose() {
|
|
cmd += " --verbose"
|
|
}
|
|
if i.IsCask() {
|
|
cmd += " --cask"
|
|
}
|
|
if opts.UpdateFlags != nil {
|
|
cmd += " " + *opts.UpdateFlags
|
|
} else if opts.Flags != nil {
|
|
cmd += " " + *opts.Flags
|
|
}
|
|
err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name))
|
|
i.markBrewRepoUpdated()
|
|
return err
|
|
}
|
|
|
|
// GetFullName returns the full name of the package, including the tap if specified.
|
|
func (i *BrewInstaller) GetFullName() string {
|
|
name := *i.Info.Name
|
|
if i.GetOpts().Tap != nil {
|
|
name = *i.GetOpts().Tap + "/" + name
|
|
}
|
|
return name
|
|
}
|
|
|
|
// handleBrewRepoUpdate manages brew's auto-update behavior according to the configured mode.
|
|
// For "once" (default): lets the first brew command auto-update, then suppresses subsequent ones.
|
|
// For "always": does nothing (brew auto-updates every time).
|
|
// For "never": always suppresses auto-update.
|
|
func (i *BrewInstaller) handleBrewRepoUpdate() {
|
|
mode := i.Config.GetRepoUpdateMode(appconfig.InstallerTypeBrew)
|
|
switch mode {
|
|
case appconfig.RepoUpdateNever:
|
|
_ = os.Setenv("HOMEBREW_NO_AUTO_UPDATE", "1")
|
|
case appconfig.RepoUpdateAlways:
|
|
// Let brew auto-update every time
|
|
default: // once
|
|
if IsRepoUpdated("brew") {
|
|
_ = os.Setenv("HOMEBREW_NO_AUTO_UPDATE", "1")
|
|
}
|
|
}
|
|
}
|
|
|
|
// markBrewRepoUpdated marks brew's repo as updated (for "once" mode tracking).
|
|
func (i *BrewInstaller) markBrewRepoUpdated() {
|
|
if i.Config.GetRepoUpdateMode(appconfig.InstallerTypeBrew) == appconfig.RepoUpdateOnce {
|
|
MarkRepoUpdated("brew")
|
|
}
|
|
}
|
|
|
|
// CheckNeedsUpdate implements IInstaller.
|
|
func (i *BrewInstaller) CheckNeedsUpdate() (bool, error) {
|
|
if i.HasCustomUpdateCheck() {
|
|
return i.RunCustomUpdateCheck()
|
|
}
|
|
|
|
i.handleBrewRepoUpdate()
|
|
name := i.GetFullName()
|
|
cmd := exec.Command("brew", "outdated", "--json", name)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
logger.Error("Failed to get stdout pipe for brew command, error: %v", err)
|
|
return false, fmt.Errorf("failed to get stdout: %w", err)
|
|
}
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
logger.Error("Failed to start brew command, error: %v", err)
|
|
return false, fmt.Errorf("failed to start brew: %w", err)
|
|
}
|
|
|
|
updateNeeded, parseErr := parseBrewOutdatedOutput(stdoutPipe, os.Stdout)
|
|
|
|
waitErr := cmd.Wait()
|
|
if waitErr != nil {
|
|
exitErr, ok := waitErr.(*exec.ExitError)
|
|
if ok {
|
|
exitCode := exitErr.ExitCode()
|
|
// 0 = no update, 1 = update available → both acceptable
|
|
if exitCode != 0 && exitCode != 1 {
|
|
logger.Error("Brew command failed with unexpected code %d", exitCode)
|
|
return false, waitErr
|
|
}
|
|
} else {
|
|
// Non-exit error (e.g. I/O), return as-is
|
|
logger.Error("Brew command failed, non-exit error: %v", waitErr)
|
|
return false, waitErr
|
|
}
|
|
}
|
|
|
|
if parseErr != nil {
|
|
logger.Error("Failed to parse brew output, error: %v", parseErr)
|
|
return false, fmt.Errorf("failed to parse brew output: %w", parseErr)
|
|
}
|
|
|
|
i.markBrewRepoUpdated()
|
|
return updateNeeded, nil
|
|
}
|
|
|
|
// parseBrewOutdatedOutput parses the JSON output of `brew outdated --json`.
|
|
// It returns true if an update is needed, false otherwise.
|
|
func parseBrewOutdatedOutput(input io.Reader, logSink io.Writer) (bool, error) {
|
|
var jsonBuf bytes.Buffer
|
|
scanner := bufio.NewScanner(input)
|
|
inJSON := false
|
|
|
|
logger.Debug("Parsing brew outdated output")
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(strings.TrimSpace(line), "{") {
|
|
inJSON = true
|
|
}
|
|
|
|
if inJSON {
|
|
jsonBuf.WriteString(line + "\n")
|
|
} else {
|
|
_, err := fmt.Fprintln(logSink, line)
|
|
if err != nil {
|
|
logger.Error("Failed to write line to log sink, error: %v", err)
|
|
return false, fmt.Errorf("failed to write line to log sink: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Parse JSON
|
|
type brewOutdatedJSON struct {
|
|
Formulae []any `json:"formulae"`
|
|
Casks []any `json:"casks"`
|
|
}
|
|
var parsed brewOutdatedJSON
|
|
logger.Debug("Unmarshalling JSON from brew outdated output: %s", jsonBuf.String())
|
|
if err := json.Unmarshal(jsonBuf.Bytes(), &parsed); err != nil {
|
|
logger.Error("Failed to unmarshal JSON from brew outdated output, error: %v", err)
|
|
return false, err
|
|
}
|
|
return len(parsed.Formulae) > 0 || len(parsed.Casks) > 0, nil
|
|
}
|
|
|
|
// CheckIsInstalled implements IInstaller.
|
|
func (i *BrewInstaller) CheckIsInstalled() (bool, error) {
|
|
if i.HasCustomInstallCheck() {
|
|
return i.RunCustomInstallCheck()
|
|
}
|
|
return i.RunCmdGetSuccess(utils.GetShellWhich(), i.GetBinName())
|
|
}
|
|
|
|
// GetData implements IInstaller.
|
|
func (i *BrewInstaller) GetData() *appconfig.InstallerData {
|
|
return i.Info
|
|
}
|
|
|
|
// GetOpts returns the parsed options for the BrewInstaller.
|
|
func (i *BrewInstaller) GetOpts() *BrewOpts {
|
|
opts := &BrewOpts{}
|
|
info := i.Info
|
|
if info.Opts != nil {
|
|
if tap, ok := (*info.Opts)["tap"].(string); ok {
|
|
opts.Tap = &tap
|
|
}
|
|
if caskVal, ok := (*info.Opts)["cask"].(bool); ok {
|
|
opts.Cask = &caskVal
|
|
}
|
|
if flags, ok := (*info.Opts)["flags"].(string); ok {
|
|
opts.Flags = &flags
|
|
}
|
|
if installFlags, ok := (*info.Opts)["install_flags"].(string); ok {
|
|
opts.InstallFlags = &installFlags
|
|
}
|
|
if updateFlags, ok := (*info.Opts)["update_flags"].(string); ok {
|
|
opts.UpdateFlags = &updateFlags
|
|
}
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func (i *BrewInstaller) IsCask() bool {
|
|
opts := i.GetOpts()
|
|
return opts.Cask != nil && *opts.Cask
|
|
}
|
|
|
|
// GetBinName returns the binary name for the installer.
|
|
// It uses the BinName from the installer data if provided, otherwise it uses the installer name.
|
|
func (i *BrewInstaller) GetBinName() string {
|
|
info := i.GetData()
|
|
if info.BinName != nil && len(*info.BinName) > 0 {
|
|
return *info.BinName
|
|
}
|
|
return *info.Name
|
|
}
|
|
|
|
// NewBrewInstaller creates a new BrewInstaller.
|
|
func NewBrewInstaller(cfg *appconfig.AppConfig, installer *appconfig.InstallerData) *BrewInstaller {
|
|
i := &BrewInstaller{
|
|
InstallerBase: InstallerBase{Data: installer},
|
|
Config: cfg,
|
|
Info: installer,
|
|
}
|
|
|
|
return i
|
|
}
|