From 7dce7e3c615039821300577743267572c17e5b3b Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 3 Apr 2026 10:08:51 +0300 Subject: [PATCH] feat: add frequency option to limit how often installers run --- README.md | 2 + appconfig/appconfig.go | 5 +++ appconfig/installer_data.go | 4 ++ cmd/root.go | 39 ++++++++++------- docs/command-line-interface.md | 1 + docs/installer-configuration.md | 38 ++++++++++++++++ installer/frequency.go | 73 ++++++++++++++++++++++++++++++ installer/frequency_test.go | 78 +++++++++++++++++++++++++++++++++ installer/installer.go | 20 +++++++++ utils/duration.go | 60 +++++++++++++++++++++++++ utils/duration_test.go | 41 +++++++++++++++++ 11 files changed, 344 insertions(+), 17 deletions(-) create mode 100644 installer/frequency.go create mode 100644 installer/frequency_test.go create mode 100644 utils/duration.go create mode 100644 utils/duration_test.go diff --git a/README.md b/README.md index da9af7c..9929fdd 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 0c9819d..271b17c 100755 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -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) diff --git a/appconfig/installer_data.go b/appconfig/installer_data.go index b7ba0ce..9753a30 100755 --- a/appconfig/installer_data.go +++ b/appconfig/installer_data.go @@ -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. diff --git a/cmd/root.go b/cmd/root.go index 40c8a34..6a134b4 100644 --- a/cmd/root.go +++ b/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 diff --git a/docs/command-line-interface.md b/docs/command-line-interface.md index dec0aa6..a5345ab 100755 --- a/docs/command-line-interface.md +++ b/docs/command-line-interface.md @@ -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. | diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 9003fa8..b19e6c5 100755 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -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 diff --git a/installer/frequency.go b/installer/frequency.go new file mode 100644 index 0000000..9587936 --- /dev/null +++ b/installer/frequency.go @@ -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) +} diff --git a/installer/frequency_test.go b/installer/frequency_test.go new file mode 100644 index 0000000..405cc64 --- /dev/null +++ b/installer/frequency_test.go @@ -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) +} diff --git a/installer/installer.go b/installer/installer.go index 3ea36fb..35709b8 100755 --- a/installer/installer.go +++ b/installer/installer.go @@ -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() diff --git a/utils/duration.go b/utils/duration.go new file mode 100644 index 0000000..078286a --- /dev/null +++ b/utils/duration.go @@ -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 +} diff --git a/utils/duration_test.go b/utils/duration_test.go new file mode 100644 index 0000000..21d5fb2 --- /dev/null +++ b/utils/duration_test.go @@ -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) + } + }) + } +}