From 7de60d48fdb16cead1bc333a5cdd8959252f5552 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 3 Dec 2025 21:01:37 +0200 Subject: [PATCH] feat: pacman/yay installers --- README.md | 5 ++ appconfig/installer_data.go | 2 + docs/installer-configuration.md | 26 ++++++ install.sh | 2 - installer/installer.go | 2 + installer/installer_defaults.go | 4 + installer/pacman_installer.go | 130 +++++++++++++++++++++++++++++ installer/pacman_installer_test.go | 124 +++++++++++++++++++++++++++ 8 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 installer/pacman_installer.go create mode 100644 installer/pacman_installer_test.go diff --git a/README.md b/README.md index aaa8825..6a55090 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,11 @@ For a full list with all the supported options, see [the docs](./docs/installer- - Installs packages using apt/apk install. - Use `type: apt` for `apt install`, and `type: apk` for `apk add`. +- **`pacman`/`yay`** + + - Installs packages using pacman or yay (Arch Linux). + - Use `type: pacman` for official repository packages, and `type: yay` for AUR packages. + - **`pipx`** - Installs packages using pipx. diff --git a/appconfig/installer_data.go b/appconfig/installer_data.go index 258fd97..2348040 100644 --- a/appconfig/installer_data.go +++ b/appconfig/installer_data.go @@ -65,6 +65,8 @@ const ( InstallerTypeYarn InstallerType = "yarn" // InstallerTypeYarn represents a yarn package installer. InstallerTypePipx InstallerType = "pipx" // InstallerTypePipx represents a pipx package installer. InstallerTypeManifest InstallerType = "manifest" // InstallerTypeManifest represents a manifest file installer. + InstallerTypePacman InstallerType = "pacman" // InstallerTypePacman represents a pacman package installer. + InstallerTypeYay InstallerType = "yay" // InstallerTypeYay represents a yay (AUR helper) package installer. ) // Environ returns the combined environment variables for the installer as a slice of strings. diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 9e1bffb..ea93a6b 100644 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -212,6 +212,15 @@ These fields are shared by all installer types. Some fields may vary in behavior - **Description**: Installs packages using apt install or apt add. - Use `type: apt` for `apt install`, and `type: apk` for `apk add`. +- **`pacman`/`yay`** + + - **Description**: Installs packages using pacman or yay (Arch Linux). + - Use `type: pacman` for official Arch repository packages. + - Use `type: yay` for AUR (Arch User Repository) packages. + - Both use `--noconfirm` for non-interactive installation. + - **Options**: + - `opts.needed`: Skip reinstalling up-to-date packages (`--needed` flag). + - **`pipx`** - **Description**: Installs packages using pipx. @@ -380,6 +389,23 @@ install: only: ['linux'] ``` +### pacman/yay + +```yaml +install: + # Install from official Arch repositories + - name: neovim + type: pacman + bin_name: nvim + opts: + needed: true # Skip if already up-to-date + + # Install from AUR using yay + - name: visual-studio-code-bin + type: yay + bin_name: code +``` + ### docker ```yaml diff --git a/install.sh b/install.sh index f6f505b..7c1d368 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,4 @@ #!/usr/bin/env sh -# Portable installer for sofmani (no Bashisms) -# Env vars you can override: INSTALL_DIR, REPO set -eu diff --git a/installer/installer.go b/installer/installer.go index c58981b..eaa93bc 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -49,6 +49,8 @@ func GetInstaller(config *appconfig.AppConfig, data *appconfig.InstallerData) (I return NewNpmInstaller(config, data), nil case appconfig.InstallerTypeApt, appconfig.InstallerTypeApk: return NewAptInstaller(config, data), nil + case appconfig.InstallerTypePacman, appconfig.InstallerTypeYay: + return NewPacmanInstaller(config, data), nil case appconfig.InstallerTypePipx: return NewPipxInstaller(config, data), nil case appconfig.InstallerTypeGitHubRelease: diff --git a/installer/installer_defaults.go b/installer/installer_defaults.go index 74c3355..a4f04af 100644 --- a/installer/installer_defaults.go +++ b/installer/installer_defaults.go @@ -127,5 +127,9 @@ func FillDefaults(data *appconfig.InstallerData) { data.Platforms = &platform.Platforms{ Only: &[]platform.Platform{platform.PlatformLinux}, } + case appconfig.InstallerTypePacman, appconfig.InstallerTypeYay: + data.Platforms = &platform.Platforms{ + Only: &[]platform.Platform{platform.PlatformLinux}, + } } } diff --git a/installer/pacman_installer.go b/installer/pacman_installer.go new file mode 100644 index 0000000..925bb1f --- /dev/null +++ b/installer/pacman_installer.go @@ -0,0 +1,130 @@ +package installer + +import ( + "github.com/chenasraf/sofmani/appconfig" +) + +// PacmanInstaller is an installer for pacman and yay packages. +type PacmanInstaller struct { + InstallerBase + // Config is the application configuration. + Config *appconfig.AppConfig + // Info is the installer data. + Info *appconfig.InstallerData + // PackageManager is the package manager to use (pacman or yay). + PackageManager PacmanPackageManager +} + +// PacmanOpts represents options for the PacmanInstaller. +type PacmanOpts struct { + // Needed skips reinstalling up-to-date packages (--needed flag). + Needed *bool +} + +// PacmanPackageManager represents an Arch Linux package manager type. +type PacmanPackageManager string + +// Constants for supported Arch Linux package managers. +const ( + PackageManagerPacman PacmanPackageManager = "pacman" // PackageManagerPacman represents the pacman package manager. + PackageManagerYay PacmanPackageManager = "yay" // PackageManagerYay represents the yay AUR helper. +) + +// Validate validates the installer configuration. +func (i *PacmanInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + return errors +} + +// Install implements IInstaller. +func (i *PacmanInstaller) Install() error { + name := *i.Info.Name + args := []string{"-S", "--noconfirm"} + if i.GetOpts().Needed != nil && *i.GetOpts().Needed { + args = append(args, "--needed") + } + args = append(args, name) + return i.RunCmdPassThrough(string(i.PackageManager), args...) +} + +// Update implements IInstaller. +func (i *PacmanInstaller) Update() error { + name := *i.Info.Name + args := []string{"-S", "--noconfirm"} + if i.GetOpts().Needed != nil && *i.GetOpts().Needed { + args = append(args, "--needed") + } + args = append(args, name) + return i.RunCmdPassThrough(string(i.PackageManager), args...) +} + +// CheckNeedsUpdate implements IInstaller. +func (i *PacmanInstaller) CheckNeedsUpdate() (bool, error) { + if i.HasCustomUpdateCheck() { + return i.RunCustomUpdateCheck() + } + // -Qu lists packages that have updates available + // If the package has an update, it will be in the output + output, err := i.RunCmdGetOutput(string(i.PackageManager), "-Qu", *i.Info.Name) + if err != nil { + // No output or error means no updates needed + return false, nil + } + // If we got output, there are updates available + return len(output) > 0, nil +} + +// CheckIsInstalled implements IInstaller. +func (i *PacmanInstaller) CheckIsInstalled() (bool, error) { + if i.HasCustomInstallCheck() { + return i.RunCustomInstallCheck() + } + // Use pacman -Q to check if package is installed (works for all packages including fonts/libraries) + return i.RunCmdGetSuccess(string(i.PackageManager), "-Q", *i.Info.Name) +} + +// GetData implements IInstaller. +func (i *PacmanInstaller) GetData() *appconfig.InstallerData { + return i.Info +} + +// GetOpts returns the parsed options for the PacmanInstaller. +func (i *PacmanInstaller) GetOpts() *PacmanOpts { + opts := &PacmanOpts{} + info := i.Info + if info.Opts != nil { + if needed, ok := (*info.Opts)["needed"].(bool); ok { + opts.Needed = &needed + } + } + return opts +} + +// 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 *PacmanInstaller) GetBinName() string { + info := i.GetData() + if info.BinName != nil && len(*info.BinName) > 0 { + return *info.BinName + } + return *info.Name +} + +// NewPacmanInstaller creates a new PacmanInstaller. +func NewPacmanInstaller(cfg *appconfig.AppConfig, installer *appconfig.InstallerData) *PacmanInstaller { + var packageManager PacmanPackageManager + switch installer.Type { + case appconfig.InstallerTypePacman: + packageManager = PackageManagerPacman + case appconfig.InstallerTypeYay: + packageManager = PackageManagerYay + } + i := &PacmanInstaller{ + InstallerBase: InstallerBase{Data: installer}, + Config: cfg, + Info: installer, + PackageManager: packageManager, + } + + return i +} diff --git a/installer/pacman_installer_test.go b/installer/pacman_installer_test.go new file mode 100644 index 0000000..d03785f --- /dev/null +++ b/installer/pacman_installer_test.go @@ -0,0 +1,124 @@ +package installer + +import ( + "testing" + + "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/logger" +) + +func newTestPacmanInstaller(data *appconfig.InstallerData) *PacmanInstaller { + return &PacmanInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + PackageManager: PackageManagerPacman, + Info: data, + } +} + +func newTestYayInstaller(data *appconfig.InstallerData) *PacmanInstaller { + return &PacmanInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + PackageManager: PackageManagerYay, + Info: data, + } +} + +func TestPacmanValidation(t *testing.T) { + logger.InitLogger(false) + + // Valid pacman installer + validPacmanData := &appconfig.InstallerData{ + Name: strPtr("vim"), + Type: appconfig.InstallerTypePacman, + } + assertNoValidationErrors(t, newTestPacmanInstaller(validPacmanData).Validate()) + + // Valid yay installer + validYayData := &appconfig.InstallerData{ + Name: strPtr("visual-studio-code-bin"), + Type: appconfig.InstallerTypeYay, + } + assertNoValidationErrors(t, newTestYayInstaller(validYayData).Validate()) + + // Invalid: nil name + nilNameData := &appconfig.InstallerData{ + Name: nil, + Type: appconfig.InstallerTypePacman, + } + assertValidationError(t, newTestPacmanInstaller(nilNameData).Validate(), "name") +} + +func TestPacmanGetBinName(t *testing.T) { + logger.InitLogger(false) + + // Test default bin name (uses package name) + defaultBinData := &appconfig.InstallerData{ + Name: strPtr("neovim"), + Type: appconfig.InstallerTypePacman, + } + installer := newTestPacmanInstaller(defaultBinData) + if installer.GetBinName() != "neovim" { + t.Errorf("expected bin name 'neovim', got '%s'", installer.GetBinName()) + } + + // Test custom bin name + customBinName := "nvim" + customBinData := &appconfig.InstallerData{ + Name: strPtr("neovim"), + Type: appconfig.InstallerTypePacman, + BinName: &customBinName, + } + installerWithCustomBin := newTestPacmanInstaller(customBinData) + if installerWithCustomBin.GetBinName() != "nvim" { + t.Errorf("expected bin name 'nvim', got '%s'", installerWithCustomBin.GetBinName()) + } +} + +func TestPacmanGetOpts(t *testing.T) { + logger.InitLogger(false) + + // Test default opts (no options set) + defaultData := &appconfig.InstallerData{ + Name: strPtr("vim"), + Type: appconfig.InstallerTypePacman, + } + installer := newTestPacmanInstaller(defaultData) + opts := installer.GetOpts() + if opts.Needed != nil { + t.Errorf("expected Needed to be nil, got %v", *opts.Needed) + } + + // Test with needed option set to true + neededData := &appconfig.InstallerData{ + Name: strPtr("vim"), + Type: appconfig.InstallerTypePacman, + Opts: &map[string]any{ + "needed": true, + }, + } + installerWithNeeded := newTestPacmanInstaller(neededData) + optsWithNeeded := installerWithNeeded.GetOpts() + if optsWithNeeded.Needed == nil || !*optsWithNeeded.Needed { + t.Errorf("expected Needed to be true") + } + + // Test with needed option set to false + notNeededData := &appconfig.InstallerData{ + Name: strPtr("vim"), + Type: appconfig.InstallerTypePacman, + Opts: &map[string]any{ + "needed": false, + }, + } + installerNotNeeded := newTestPacmanInstaller(notNeededData) + optsNotNeeded := installerNotNeeded.GetOpts() + if optsNotNeeded.Needed == nil || *optsNotNeeded.Needed { + t.Errorf("expected Needed to be false") + } +}