mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: add repo_update control modes
This commit is contained in:
@@ -86,6 +86,9 @@ example configuration to demonstrate most of its options.
|
||||
```yaml
|
||||
debug: true # Global debug mode (optional).
|
||||
check_updates: true # Enable update checking (optional).
|
||||
repo_update: # Control repo index updates per type (optional).
|
||||
brew: once # Run brew update once per run (default).
|
||||
apt: once # Run apt update once per run (default).
|
||||
defaults: # Define default behaviors for installer types.
|
||||
type:
|
||||
brew:
|
||||
@@ -172,6 +175,7 @@ For a full breakdown with all the supported options, see
|
||||
| ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `debug` | Boolean | Enable or disable debug mode. Default: `false`. |
|
||||
| `check_updates` | Boolean | Enable or disable checking for updates before running operations. Default: `false`. |
|
||||
| `repo_update` | Object | Controls repo index updates per installer type (e.g. `apt update`, `brew update`). Values: `once` (default), `always`, `never`. Supported types: `brew`, `apt`, `apk`. |
|
||||
| `summary` | Boolean | Enable or disable the installation summary at the end. Default: `true`. |
|
||||
| `category_display` | String | Controls how category headers are rendered. Values: `border` (default), `border-compact`, `minimal`. |
|
||||
| `defaults` | Object | Defaults to apply to all installer types, such as specifying supported platforms or commonly used flags. |
|
||||
|
||||
@@ -25,6 +25,18 @@ const (
|
||||
CategoryDisplayMinimal CategoryDisplayMode = "minimal"
|
||||
)
|
||||
|
||||
// RepoUpdateMode controls how repository index updates are handled for a package manager.
|
||||
type RepoUpdateMode string
|
||||
|
||||
const (
|
||||
// RepoUpdateOnce runs the repository update at most once per sofmani run (default).
|
||||
RepoUpdateOnce RepoUpdateMode = "once"
|
||||
// RepoUpdateAlways runs the repository update before every install/update check.
|
||||
RepoUpdateAlways RepoUpdateMode = "always"
|
||||
// RepoUpdateNever skips the repository update entirely.
|
||||
RepoUpdateNever RepoUpdateMode = "never"
|
||||
)
|
||||
|
||||
// AppConfig represents the main application configuration.
|
||||
type AppConfig struct {
|
||||
// Debug enables or disables debug mode.
|
||||
@@ -35,6 +47,9 @@ type AppConfig struct {
|
||||
Summary *bool `json:"summary" yaml:"summary"`
|
||||
// CategoryDisplay controls how category headers are rendered.
|
||||
CategoryDisplay *CategoryDisplayMode `json:"category_display" yaml:"category_display"`
|
||||
// RepoUpdate controls repository index update behavior per installer type.
|
||||
// Supported types: brew, apt, apk. Values: "once" (default), "always", "never".
|
||||
RepoUpdate *map[InstallerType]RepoUpdateMode `json:"repo_update" yaml:"repo_update"`
|
||||
// Install is a list of installers to run.
|
||||
Install []InstallerData `json:"install" yaml:"install"`
|
||||
// Defaults provides default configurations for installer types.
|
||||
@@ -49,6 +64,17 @@ type AppConfig struct {
|
||||
Filter []string
|
||||
}
|
||||
|
||||
// GetRepoUpdateMode returns the repo update mode for the given installer type,
|
||||
// defaulting to RepoUpdateOnce.
|
||||
func (c *AppConfig) GetRepoUpdateMode(t InstallerType) RepoUpdateMode {
|
||||
if c.RepoUpdate != nil {
|
||||
if mode, ok := (*c.RepoUpdate)[t]; ok {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
return RepoUpdateOnce
|
||||
}
|
||||
|
||||
// AppCliConfig represents the command-line interface configuration.
|
||||
type AppCliConfig struct {
|
||||
// ConfigFile is the path to the configuration file.
|
||||
|
||||
@@ -125,6 +125,53 @@ install:
|
||||
assert.False(t, *config.CheckUpdates)
|
||||
}
|
||||
|
||||
func TestGetRepoUpdateMode(t *testing.T) {
|
||||
t.Run("defaults to once when not configured", func(t *testing.T) {
|
||||
config := AppConfig{}
|
||||
assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeBrew))
|
||||
assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApt))
|
||||
})
|
||||
|
||||
t.Run("returns configured mode", func(t *testing.T) {
|
||||
repoUpdate := map[InstallerType]RepoUpdateMode{
|
||||
InstallerTypeBrew: RepoUpdateNever,
|
||||
InstallerTypeApt: RepoUpdateAlways,
|
||||
}
|
||||
config := AppConfig{RepoUpdate: &repoUpdate}
|
||||
assert.Equal(t, RepoUpdateNever, config.GetRepoUpdateMode(InstallerTypeBrew))
|
||||
assert.Equal(t, RepoUpdateAlways, config.GetRepoUpdateMode(InstallerTypeApt))
|
||||
})
|
||||
|
||||
t.Run("defaults to once for unconfigured type", func(t *testing.T) {
|
||||
repoUpdate := map[InstallerType]RepoUpdateMode{
|
||||
InstallerTypeBrew: RepoUpdateNever,
|
||||
}
|
||||
config := AppConfig{RepoUpdate: &repoUpdate}
|
||||
assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApt))
|
||||
})
|
||||
|
||||
t.Run("parses from yaml", func(t *testing.T) {
|
||||
file, err := os.CreateTemp("", "config.*.yaml")
|
||||
assert.NoError(t, err)
|
||||
defer func() { assert.NoError(t, os.Remove(file.Name())) }()
|
||||
|
||||
_, err = file.WriteString(`
|
||||
repo_update:
|
||||
brew: never
|
||||
apt: always
|
||||
apk: once
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, file.Close())
|
||||
|
||||
config, err := ParseConfigFrom(file.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, RepoUpdateNever, config.GetRepoUpdateMode(InstallerTypeBrew))
|
||||
assert.Equal(t, RepoUpdateAlways, config.GetRepoUpdateMode(InstallerTypeApt))
|
||||
assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApk))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindConfigFile(t *testing.T) {
|
||||
// Create a temporary config file
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -23,6 +23,22 @@ Here is a breakdown of all configuration options:
|
||||
- Enable or disable checking for updates before running operations.
|
||||
- Default: `false`.
|
||||
|
||||
- **`repo_update`** (Object)
|
||||
- Controls how repository index updates (e.g. `apt update`, `brew update`) are handled per
|
||||
installer type. Keys are installer types, values are one of:
|
||||
- `once` — Run the repo update at most once per sofmani run (default).
|
||||
- `always` — Run the repo update before every install/update operation.
|
||||
- `never` — Skip the repo update entirely.
|
||||
- Supported types: `brew`, `apt`, `apk`.
|
||||
- Default: `once` for all supported types.
|
||||
- Example:
|
||||
```yaml
|
||||
repo_update:
|
||||
brew: once
|
||||
apt: always
|
||||
apk: never
|
||||
```
|
||||
|
||||
- **`summary`** (Boolean)
|
||||
- Enable or disable the installation summary at the end.
|
||||
- The summary shows newly installed and upgraded software in a hierarchical format.
|
||||
@@ -73,6 +89,9 @@ debug: false
|
||||
check_updates: true
|
||||
summary: true
|
||||
category_display: border
|
||||
repo_update:
|
||||
brew: once
|
||||
apt: once
|
||||
defaults:
|
||||
type:
|
||||
brew:
|
||||
|
||||
@@ -493,6 +493,9 @@ install:
|
||||
|
||||
- **`brew`**
|
||||
- **Description**: Installs packages using Homebrew.
|
||||
- **Repo update**: Brew auto-updates its index on each command. By default, sofmani lets the first
|
||||
brew command auto-update normally and suppresses it for subsequent ones (`once` mode). Configure
|
||||
via the top-level [`repo_update`](./configuration-reference.md#global-options) option.
|
||||
|
||||
- **Options**:
|
||||
- `opts.tap`: Name of the tap to install the package from.
|
||||
@@ -513,6 +516,9 @@ install:
|
||||
- **`apt`/`apk`**
|
||||
- **Description**: Installs packages using apt install or apt add.
|
||||
- Use `type: apt` for `apt install`, and `type: apk` for `apk add`.
|
||||
- **Repo update**: Runs `apt update` or `apk update` before installing. By default, the update
|
||||
runs at most once per sofmani run (`once` mode). Configure via the top-level
|
||||
[`repo_update`](./configuration-reference.md#global-options) option.
|
||||
- **Options**:
|
||||
- `opts.flags`: Additional flags to pass to commands (fallback for install/update).
|
||||
- `opts.install_flags`: Additional flags to pass only during install.
|
||||
|
||||
@@ -43,13 +43,26 @@ func (i *AptInstaller) Validate() []ValidationError {
|
||||
return errors
|
||||
}
|
||||
|
||||
// runRepoUpdate runs the package manager's repo update according to the configured mode.
|
||||
func (i *AptInstaller) runRepoUpdate() error {
|
||||
mode := i.Config.GetRepoUpdateMode(i.Info.Type)
|
||||
switch mode {
|
||||
case appconfig.RepoUpdateNever:
|
||||
return nil
|
||||
case appconfig.RepoUpdateAlways:
|
||||
return i.RunCmdPassThrough(string(i.PackageManager), "update")
|
||||
default: // once
|
||||
return RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error {
|
||||
return i.RunCmdPassThrough(string(i.PackageManager), "update")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Install implements IInstaller.
|
||||
func (i *AptInstaller) Install() error {
|
||||
name := *i.Info.Name
|
||||
opts := i.GetOpts()
|
||||
err := RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error {
|
||||
return i.RunCmdPassThrough(string(i.PackageManager), "update")
|
||||
})
|
||||
err := i.runRepoUpdate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,9 +124,7 @@ func (i *AptInstaller) CheckNeedsUpdate() (bool, error) {
|
||||
if i.HasCustomUpdateCheck() {
|
||||
return i.RunCustomUpdateCheck()
|
||||
}
|
||||
err := RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error {
|
||||
return i.RunCmdPassThrough(string(i.PackageManager), "update")
|
||||
})
|
||||
err := i.runRepoUpdate()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func (i *BrewInstaller) Validate() []ValidationError {
|
||||
func (i *BrewInstaller) Install() error {
|
||||
name := i.GetFullName()
|
||||
opts := i.GetOpts()
|
||||
i.suppressBrewAutoUpdate()
|
||||
i.handleBrewRepoUpdate()
|
||||
cmd := "brew install"
|
||||
if i.IsVerbose() {
|
||||
cmd += " --verbose"
|
||||
@@ -69,7 +69,7 @@ func (i *BrewInstaller) Install() error {
|
||||
cmd += " " + *opts.Flags
|
||||
}
|
||||
err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name))
|
||||
MarkRepoUpdated("brew")
|
||||
i.markBrewRepoUpdated()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (i *BrewInstaller) Install() error {
|
||||
func (i *BrewInstaller) Update() error {
|
||||
name := i.GetFullName()
|
||||
opts := i.GetOpts()
|
||||
i.suppressBrewAutoUpdate()
|
||||
i.handleBrewRepoUpdate()
|
||||
cmd := "brew upgrade"
|
||||
if i.IsVerbose() {
|
||||
cmd += " --verbose"
|
||||
@@ -91,7 +91,7 @@ func (i *BrewInstaller) Update() error {
|
||||
cmd += " " + *opts.Flags
|
||||
}
|
||||
err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name))
|
||||
MarkRepoUpdated("brew")
|
||||
i.markBrewRepoUpdated()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -104,11 +104,28 @@ func (i *BrewInstaller) GetFullName() string {
|
||||
return name
|
||||
}
|
||||
|
||||
// suppressBrewAutoUpdate sets HOMEBREW_NO_AUTO_UPDATE=1 if brew has already
|
||||
// auto-updated during this run, preventing redundant repo syncs.
|
||||
func (i *BrewInstaller) suppressBrewAutoUpdate() {
|
||||
if IsRepoUpdated("brew") {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +135,7 @@ func (i *BrewInstaller) CheckNeedsUpdate() (bool, error) {
|
||||
return i.RunCustomUpdateCheck()
|
||||
}
|
||||
|
||||
i.suppressBrewAutoUpdate()
|
||||
i.handleBrewRepoUpdate()
|
||||
name := i.GetFullName()
|
||||
cmd := exec.Command("brew", "outdated", "--json", name)
|
||||
|
||||
@@ -159,7 +176,7 @@ func (i *BrewInstaller) CheckNeedsUpdate() (bool, error) {
|
||||
return false, fmt.Errorf("failed to parse brew output: %w", parseErr)
|
||||
}
|
||||
|
||||
MarkRepoUpdated("brew")
|
||||
i.markBrewRepoUpdated()
|
||||
return updateNeeded, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/sofmani/appconfig"
|
||||
"github.com/chenasraf/sofmani/logger"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -82,3 +86,143 @@ func TestResetRepoUpdateTracker(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
assert.False(t, IsRepoUpdated("key"))
|
||||
}
|
||||
|
||||
func newAptInstallerWithMode(mode appconfig.RepoUpdateMode) *AptInstaller {
|
||||
repoUpdate := map[appconfig.InstallerType]appconfig.RepoUpdateMode{
|
||||
appconfig.InstallerTypeApt: mode,
|
||||
}
|
||||
return &AptInstaller{
|
||||
InstallerBase: InstallerBase{Data: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeApt}},
|
||||
Config: &appconfig.AppConfig{RepoUpdate: &repoUpdate},
|
||||
Info: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeApt},
|
||||
PackageManager: AptPackageManager("true"), // "true" command always succeeds
|
||||
}
|
||||
}
|
||||
|
||||
func TestAptRepoUpdateMode(t *testing.T) {
|
||||
logger.InitLogger(false)
|
||||
|
||||
t.Run("never mode skips repo update", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
inst := newAptInstallerWithMode(appconfig.RepoUpdateNever)
|
||||
|
||||
err := inst.runRepoUpdate()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, IsRepoUpdated("true-update"), "tracker should not be set in never mode")
|
||||
})
|
||||
|
||||
t.Run("once mode runs repo update only once", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
callCount := 0
|
||||
origFn := RunRepoUpdateOnce
|
||||
|
||||
inst := newAptInstallerWithMode(appconfig.RepoUpdateOnce)
|
||||
|
||||
// First call should run
|
||||
err := inst.runRepoUpdate()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, IsRepoUpdated("true-update"), "tracker should be set after first call")
|
||||
|
||||
// Second call should be skipped by RunRepoUpdateOnce
|
||||
// We verify by checking the tracker was already set
|
||||
_ = RunRepoUpdateOnce("true-update", func() error {
|
||||
callCount++
|
||||
return nil
|
||||
})
|
||||
assert.Equal(t, 0, callCount, "function should not run again for same key")
|
||||
_ = origFn
|
||||
})
|
||||
|
||||
t.Run("always mode runs repo update every time", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
inst := newAptInstallerWithMode(appconfig.RepoUpdateAlways)
|
||||
|
||||
// Should succeed and NOT use the tracker
|
||||
err := inst.runRepoUpdate()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, IsRepoUpdated("true-update"), "tracker should not be set in always mode")
|
||||
|
||||
// Second call should also succeed (not blocked by tracker)
|
||||
err = inst.runRepoUpdate()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func newBrewInstallerWithMode(mode appconfig.RepoUpdateMode) *BrewInstaller {
|
||||
repoUpdate := map[appconfig.InstallerType]appconfig.RepoUpdateMode{
|
||||
appconfig.InstallerTypeBrew: mode,
|
||||
}
|
||||
return &BrewInstaller{
|
||||
InstallerBase: InstallerBase{Data: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeBrew}},
|
||||
Config: &appconfig.AppConfig{RepoUpdate: &repoUpdate},
|
||||
Info: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeBrew},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrewRepoUpdateMode(t *testing.T) {
|
||||
logger.InitLogger(false)
|
||||
|
||||
t.Run("never mode always suppresses auto-update", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
_ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE")
|
||||
|
||||
inst := newBrewInstallerWithMode(appconfig.RepoUpdateNever)
|
||||
inst.handleBrewRepoUpdate()
|
||||
|
||||
assert.Equal(t, "1", os.Getenv("HOMEBREW_NO_AUTO_UPDATE"))
|
||||
|
||||
// Cleanup
|
||||
_ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE")
|
||||
})
|
||||
|
||||
t.Run("always mode never suppresses auto-update", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
_ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE")
|
||||
|
||||
inst := newBrewInstallerWithMode(appconfig.RepoUpdateAlways)
|
||||
|
||||
// Even after marking as updated, always mode should not suppress
|
||||
MarkRepoUpdated("brew")
|
||||
inst.handleBrewRepoUpdate()
|
||||
|
||||
assert.Empty(t, os.Getenv("HOMEBREW_NO_AUTO_UPDATE"))
|
||||
})
|
||||
|
||||
t.Run("once mode lets first through then suppresses", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
_ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE")
|
||||
|
||||
inst := newBrewInstallerWithMode(appconfig.RepoUpdateOnce)
|
||||
|
||||
// First call: brew not yet updated, should NOT suppress
|
||||
inst.handleBrewRepoUpdate()
|
||||
assert.Empty(t, os.Getenv("HOMEBREW_NO_AUTO_UPDATE"), "first call should not suppress")
|
||||
|
||||
// Simulate first brew command completing
|
||||
inst.markBrewRepoUpdated()
|
||||
assert.True(t, IsRepoUpdated("brew"), "tracker should be set after mark")
|
||||
|
||||
// Second call: brew already updated, should suppress
|
||||
inst.handleBrewRepoUpdate()
|
||||
assert.Equal(t, "1", os.Getenv("HOMEBREW_NO_AUTO_UPDATE"), "second call should suppress")
|
||||
|
||||
// Cleanup
|
||||
_ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE")
|
||||
})
|
||||
|
||||
t.Run("markBrewRepoUpdated only marks in once mode", func(t *testing.T) {
|
||||
ResetRepoUpdateTracker()
|
||||
|
||||
instAlways := newBrewInstallerWithMode(appconfig.RepoUpdateAlways)
|
||||
instAlways.markBrewRepoUpdated()
|
||||
assert.False(t, IsRepoUpdated("brew"), "should not mark in always mode")
|
||||
|
||||
instNever := newBrewInstallerWithMode(appconfig.RepoUpdateNever)
|
||||
instNever.markBrewRepoUpdated()
|
||||
assert.False(t, IsRepoUpdated("brew"), "should not mark in never mode")
|
||||
|
||||
instOnce := newBrewInstallerWithMode(appconfig.RepoUpdateOnce)
|
||||
instOnce.markBrewRepoUpdated()
|
||||
assert.True(t, IsRepoUpdated("brew"), "should mark in once mode")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user