feat: add frequency option to limit how often installers run

This commit is contained in:
2026-04-03 10:08:51 +03:00
parent e193b41973
commit 7dce7e3c61
11 changed files with 344 additions and 17 deletions

View File

@@ -146,6 +146,7 @@ The following flags are supported to customize behavior:
| `-s`, `--summary` | Enable installation summary (default). |
| `-S`, `--no-summary` | Disable installation summary. |
| `-f`, `--filter` | Filter by installer name (can be used multiple times) |
| `--ignore-frequency` | Ignore frequency limits and run all installers. |
| `-h`, `--help` | Display help information and exit. |
| `-v`, `--version` | Display version information and exit. |
@@ -222,6 +223,7 @@ See [Installer Configuration](./docs/installer-configuration.md#categories) for
| `env_shell` | Object (optional) | Shell to use for command executions. See `env_shell` subfields below. |
| `env_shell.macos` | String (optional) | Shell to use for macOS command executions. If not specified, the default shell will be used. |
| `env_shell.linux` | String (optional) | Shell to use for Linux command executions. If not specified, the default shell will be used. |
| `frequency` | String (optional) | Limits how often the installer runs. After a successful install/update, the next run is skipped until the duration elapses. Supports units: `s`, `m`, `h`, `d`, `w` (e.g., `1d`, `1w`, `12h`). Use `--ignore-frequency` to bypass. |
| `skip_summary` | Boolean or Object | Exclude this installer from the summary. Set to `true` to skip both install/update summaries, or use `{install: true}` / `{update: true}` for granular control. Useful for installers that always run. |
### Supported `type` of Installers

View File

@@ -62,6 +62,8 @@ type AppConfig struct {
MachineAliases *map[string]string `json:"machine_aliases" yaml:"machine_aliases"`
// Filter is a list of installer names to filter by.
Filter []string
// IgnoreFrequency overrides frequency checks, running all installers regardless.
IgnoreFrequency bool
}
// GetRepoUpdateMode returns the repo update mode for the given installer type,
@@ -93,6 +95,8 @@ type AppCliConfig struct {
ShowLogFile bool
// ShowMachineID indicates that only the machine ID should be shown.
ShowMachineID bool
// IgnoreFrequency overrides frequency checks, running all installers regardless.
IgnoreFrequency bool
}
// AppConfigDefaults provides default configurations for installer types.
@@ -131,6 +135,7 @@ func ParseConfig(overrides *AppCliConfig) (*AppConfig, error) {
appConfig.Summary = overrides.Summary
}
appConfig.Filter = overrides.Filter
appConfig.IgnoreFrequency = overrides.IgnoreFrequency
return appConfig, nil
}
return nil, fmt.Errorf("unsupported config file extension %s (filename: %s)", ext, file)

View File

@@ -89,6 +89,10 @@ type InstallerData struct {
SkipSummary *SkipSummary `json:"skip_summary" yaml:"skip_summary"`
// Verbose enables verbose output for the installer's native commands.
Verbose *bool `json:"verbose" yaml:"verbose"`
// Frequency is a prettified duration (e.g. "1d", "1w", "3m") that limits how often
// the installer runs. After a successful install/update, the next run will be skipped
// until the frequency period has elapsed.
Frequency *string `json:"frequency" yaml:"frequency"`
}
// InstallerType represents the type of an installer.

View File

@@ -14,15 +14,16 @@ import (
var (
// Flag variables
debug bool
noDebug bool
update bool
noUpdate bool
summary bool
noSummary bool
filter []string
logFile string
machineID bool
debug bool
noDebug bool
update bool
noUpdate bool
summary bool
noSummary bool
filter []string
logFile string
machineID bool
ignoreFrequency bool
// The parsed CLI config
cliConfig *appconfig.AppCliConfig
@@ -129,6 +130,9 @@ func init() {
// Machine ID flag
rootCmd.Flags().BoolVarP(&machineID, "machine-id", "m", false, "Show machine ID and exit")
// Ignore frequency flag
rootCmd.Flags().BoolVar(&ignoreFrequency, "ignore-frequency", false, "Ignore frequency limits and run all installers")
}
// SetVersion sets the version for the root command.
@@ -142,14 +146,15 @@ func SetVersion(version string) {
// buildCliConfig creates an AppCliConfig from the parsed Cobra flags.
func buildCliConfig(cmd *cobra.Command, args []string) *appconfig.AppCliConfig {
config := &appconfig.AppCliConfig{
ConfigFile: "",
Debug: nil,
CheckUpdates: nil,
Summary: nil,
Filter: filter,
LogFile: nil,
ShowLogFile: false,
ShowMachineID: machineID,
ConfigFile: "",
Debug: nil,
CheckUpdates: nil,
Summary: nil,
Filter: filter,
LogFile: nil,
ShowLogFile: false,
ShowMachineID: machineID,
IgnoreFrequency: ignoreFrequency,
}
// Handle debug flag

View File

@@ -29,6 +29,7 @@ You can call `sofmani` with the following flags to alter the behavior for the cu
| `-f`, `--filter` | Filter by installer name (can be used multiple times)\* |
| `-l`, `--log-file` | Set log file path, or show current path if no value. |
| `-m`, `--machine-id` | Show machine ID and exit. |
| `--ignore-frequency` | Ignore frequency limits and run all installers. |
| `-h`, `--help` | Display help information and exit. |
| `-v`, `--version` | Display version information and exit. |

View File

@@ -293,6 +293,44 @@ These fields are shared by all installer types. Some fields may vary in behavior
verbose: true
```
- **`frequency`**
- **Type**: String (optional)
- **Description**: Limits how often the installer runs. After a successful install or update, the
next run will be skipped until the specified duration has elapsed. The timestamp of the last
successful run is stored in the sofmani cache directory.
- **Format**: A prettified duration string. Multiple components can be combined. Supported units:
- `s` — seconds (e.g., `60s`)
- `m` — minutes (e.g., `30m`)
- `h` — hours (e.g., `12h`)
- `d` — days (e.g., `1d`)
- `w` — weeks (e.g., `1w`)
- Combined: `1d12h`, `1w2d`
- **Default**: Not set (installer runs every time).
- **Note**: Use the `--ignore-frequency` CLI flag to bypass frequency checks for all installers.
- **Examples**:
```yaml
# Only check for updates once a day
- name: neovim
type: brew
frequency: 1d
# Only run once a week
- name: sync-dotfiles
type: rsync
frequency: 1w
opts:
source: ~/.dotfiles/.config
destination: ~/.config
# Run at most every 12 hours
- name: my-tool
type: shell
frequency: 12h
opts:
command: ./install.sh
```
- **`skip_summary`**
- **Type**: Boolean or Object (optional)
- **Description**: Exclude this installer from the installation summary. Useful for installers

73
installer/frequency.go Normal file
View File

@@ -0,0 +1,73 @@
package installer
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chenasraf/sofmani/logger"
"github.com/chenasraf/sofmani/utils"
)
// frequencyCacheFileName returns the cache file name for a given installer name,
// escaping characters that are not safe for file names.
func frequencyCacheFileName(name string) string {
replacer := strings.NewReplacer(
"/", "__",
"\\", "__",
":", "__",
" ", "_",
)
return "freq_" + replacer.Replace(name)
}
// checkFrequency checks whether enough time has passed since the last successful run
// for an installer with the given name and frequency string.
// Returns true if the installer should run (frequency elapsed or no previous run).
func checkFrequency(name string, frequency string) (bool, error) {
dur, err := utils.ParsePrettyDuration(frequency)
if err != nil {
return false, err
}
cacheDir, err := utils.GetCacheDir()
if err != nil {
return true, nil // if we can't get cache dir, just run
}
cacheFile := filepath.Join(cacheDir, frequencyCacheFileName(name))
data, err := os.ReadFile(cacheFile)
if err != nil {
// No previous run recorded
return true, nil
}
ts, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil {
// Corrupt cache file, just run
logger.Debug("Invalid frequency cache for %s, will run", logger.H(name))
return true, nil
}
lastRun := time.Unix(ts, 0)
if time.Since(lastRun) < dur {
return false, nil
}
return true, nil
}
// writeFrequencyTimestamp writes the current timestamp to the frequency cache file
// for the given installer name.
func writeFrequencyTimestamp(name string) error {
cacheDir, err := utils.GetCacheDir()
if err != nil {
return err
}
cacheFile := filepath.Join(cacheDir, frequencyCacheFileName(name))
ts := strconv.FormatInt(time.Now().Unix(), 10)
return os.WriteFile(cacheFile, []byte(ts), 0644)
}

View File

@@ -0,0 +1,78 @@
package installer
import (
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/chenasraf/sofmani/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFrequencyCacheFileName(t *testing.T) {
assert.Equal(t, "freq_my-installer", frequencyCacheFileName("my-installer"))
assert.Equal(t, "freq_path__to__thing", frequencyCacheFileName("path/to/thing"))
assert.Equal(t, "freq_has_spaces", frequencyCacheFileName("has spaces"))
assert.Equal(t, "freq_back__slash", frequencyCacheFileName("back\\slash"))
}
func TestCheckFrequency_NoPreviousRun(t *testing.T) {
shouldRun, err := checkFrequency("nonexistent-installer-test", "1d")
assert.NoError(t, err)
assert.True(t, shouldRun)
}
func TestCheckFrequency_RecentRun(t *testing.T) {
name := "test-freq-recent"
cacheDir, err := utils.GetCacheDir()
require.NoError(t, err)
cacheFile := filepath.Join(cacheDir, frequencyCacheFileName(name))
ts := strconv.FormatInt(time.Now().Unix(), 10)
err = os.WriteFile(cacheFile, []byte(ts), 0644)
require.NoError(t, err)
defer func() { _ = os.Remove(cacheFile) }()
shouldRun, err := checkFrequency(name, "1d")
assert.NoError(t, err)
assert.False(t, shouldRun)
}
func TestCheckFrequency_ExpiredRun(t *testing.T) {
name := "test-freq-expired"
cacheDir, err := utils.GetCacheDir()
require.NoError(t, err)
cacheFile := filepath.Join(cacheDir, frequencyCacheFileName(name))
ts := strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10)
err = os.WriteFile(cacheFile, []byte(ts), 0644)
require.NoError(t, err)
defer func() { _ = os.Remove(cacheFile) }()
shouldRun, err := checkFrequency(name, "1d")
assert.NoError(t, err)
assert.True(t, shouldRun)
}
func TestWriteFrequencyTimestamp(t *testing.T) {
name := "test-freq-write"
cacheDir, err := utils.GetCacheDir()
require.NoError(t, err)
cacheFile := filepath.Join(cacheDir, frequencyCacheFileName(name))
defer func() { _ = os.Remove(cacheFile) }()
err = writeFrequencyTimestamp(name)
assert.NoError(t, err)
data, err := os.ReadFile(cacheFile)
require.NoError(t, err)
ts, err := strconv.ParseInt(string(data), 10, 64)
require.NoError(t, err)
assert.InDelta(t, time.Now().Unix(), ts, 2)
}

View File

@@ -263,6 +263,18 @@ func RunInstaller(config *appconfig.AppConfig, installer IInstaller) (*summary.I
return result, nil
}
// Check frequency limits (unless --ignore-frequency is set)
if !config.IgnoreFrequency && info.Frequency != nil && *info.Frequency != "" {
shouldRun, err := checkFrequency(name, *info.Frequency)
if err != nil {
logger.Warn("Failed to check frequency for %s: %v", logger.H(name), err)
} else if !shouldRun {
logger.Debug("%s: skipping due to frequency %s", logger.H(name), *info.Frequency)
result.Action = summary.ActionSkipped
return result, nil
}
}
logger.Debug("Checking %s: %s", logger.H(string(info.Type)), logger.H(name))
installed, err := installer.CheckIsInstalled()
if err != nil {
@@ -330,6 +342,14 @@ func RunInstaller(config *appconfig.AppConfig, installer IInstaller) (*summary.I
result.Action = summary.ActionInstalled
}
// Write frequency timestamp on successful install/update
if info.Frequency != nil && *info.Frequency != "" &&
(result.Action == summary.ActionInstalled || result.Action == summary.ActionUpgraded) {
if err := writeFrequencyTimestamp(name); err != nil {
logger.Warn("Failed to write frequency timestamp for %s: %v", logger.H(name), err)
}
}
// Collect child results for group/manifest installers
if provider, ok := installer.(IChildResultsProvider); ok {
result.Children = provider.GetChildResults()

60
utils/duration.go Normal file
View File

@@ -0,0 +1,60 @@
package utils
import (
"fmt"
"strconv"
"strings"
"time"
)
// ParsePrettyDuration parses a human-friendly duration string like "1d", "2w", "3m", "60s", "1h".
// Supported units: s (seconds), m (minutes), h (hours), d (days), w (weeks).
// Multiple components can be combined, e.g. "1d12h".
func ParsePrettyDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty duration string")
}
var total time.Duration
remaining := s
for len(remaining) > 0 {
// Find the first non-digit character
i := 0
for i < len(remaining) && remaining[i] >= '0' && remaining[i] <= '9' {
i++
}
if i == 0 {
return 0, fmt.Errorf("invalid duration %q: expected number", s)
}
if i >= len(remaining) {
return 0, fmt.Errorf("invalid duration %q: missing unit", s)
}
num, err := strconv.ParseInt(remaining[:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
}
unit := remaining[i]
remaining = remaining[i+1:]
switch unit {
case 's':
total += time.Duration(num) * time.Second
case 'm':
total += time.Duration(num) * time.Minute
case 'h':
total += time.Duration(num) * time.Hour
case 'd':
total += time.Duration(num) * 24 * time.Hour
case 'w':
total += time.Duration(num) * 7 * 24 * time.Hour
default:
return 0, fmt.Errorf("invalid duration %q: unknown unit %q", s, string(unit))
}
}
return total, nil
}

41
utils/duration_test.go Normal file
View File

@@ -0,0 +1,41 @@
package utils
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestParsePrettyDuration(t *testing.T) {
tests := []struct {
input string
expected time.Duration
wantErr bool
}{
{"60s", 60 * time.Second, false},
{"5m", 5 * time.Minute, false},
{"2h", 2 * time.Hour, false},
{"1d", 24 * time.Hour, false},
{"1w", 7 * 24 * time.Hour, false},
{"3d", 3 * 24 * time.Hour, false},
{"1d12h", 36 * time.Hour, false},
{"1w2d", 9 * 24 * time.Hour, false},
{"", 0, true},
{"abc", 0, true},
{"5", 0, true},
{"5x", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result, err := ParsePrettyDuration(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}