mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: add frequency option to limit how often installers run
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
39
cmd/root.go
39
cmd/root.go
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
73
installer/frequency.go
Normal 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)
|
||||
}
|
||||
78
installer/frequency_test.go
Normal file
78
installer/frequency_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
60
utils/duration.go
Normal 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
41
utils/duration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user