wheneverd 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/FEATURE_SUMMARY.md +10 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +70 -5
  6. data/lib/wheneverd/cli/diff.rb +98 -0
  7. data/lib/wheneverd/cli/init.rb +5 -1
  8. data/lib/wheneverd/cli/status.rb +37 -0
  9. data/lib/wheneverd/cli/validate.rb +50 -0
  10. data/lib/wheneverd/cli.rb +6 -0
  11. data/lib/wheneverd/dsl/context.rb +51 -8
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/job/command.rb +88 -8
  23. data/lib/wheneverd/systemd/analyze.rb +56 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/errors.rb +3 -0
  28. data/lib/wheneverd/systemd/renderer.rb +6 -64
  29. data/lib/wheneverd/systemd/unit_content_builder.rb +76 -0
  30. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  32. data/lib/wheneverd/systemd/unit_namer.rb +6 -14
  33. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  34. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  35. data/lib/wheneverd/trigger/base.rb +22 -0
  36. data/lib/wheneverd/trigger/boot.rb +8 -6
  37. data/lib/wheneverd/trigger/calendar.rb +7 -0
  38. data/lib/wheneverd/trigger/interval.rb +8 -6
  39. data/lib/wheneverd/validation.rb +89 -0
  40. data/lib/wheneverd/version.rb +1 -1
  41. data/lib/wheneverd.rb +5 -1
  42. data/test/cli_diff_test.rb +118 -0
  43. data/test/cli_init_test.rb +49 -0
  44. data/test/cli_status_test.rb +76 -0
  45. data/test/cli_validate_test.rb +81 -0
  46. data/test/domain_model_test.rb +106 -1
  47. data/test/dsl_context_shell_test.rb +23 -0
  48. data/test/dsl_loader_test.rb +23 -0
  49. data/test/job_command_test.rb +29 -0
  50. data/test/systemd_analyze_test.rb +55 -0
  51. data/test/systemd_cron_parser_test.rb +41 -25
  52. data/test/systemd_renderer_errors_test.rb +1 -1
  53. data/test/systemd_renderer_test.rb +6 -0
  54. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ef1858fab03a38b36da00bf0093d4025762263ca868d05a78c1876d8aeb6441
4
- data.tar.gz: 539fbeec1db7023059767501172d7337da50a966e0727c29879f88cd81770303
3
+ metadata.gz: ff946dbc0ba39d3e90ecf3cbed4c9ea81c1818fcd1d206f00cf6763dfe05ebe1
4
+ data.tar.gz: cd42856421c97365ff63547252ea4e8ba8a5f17dc822eea8af67b45ce8861b8f
5
5
  SHA512:
6
- metadata.gz: 2656962166f254b2d48f94d23329db78a16964eda9c164457c5586b4dabfa095077b75372b94da9f9cad38862d5548be15ba7b2567cef5d2fb2cdc3cb94d4984
7
- data.tar.gz: a06b649a079b18fd3f321bc818e322d3284c98d96bfbabaf43244b4d604193b395186d31548efdf2296fe12509f47e665f1cdf96f47e2af25512b3eae53ba20d
6
+ metadata.gz: a4d31fc560c96e1b10b7b42cb0c32e4aa222823e64bde6a17cacb891246d67caefb9e42b5f71ce23a26b14392c33eb819f42fc3e199a0b2f10a0f040bb5e5193
7
+ data.tar.gz: fc2e7a43ed41af1b7b00cb4cc8a228054071cec67ca53bb81829134ab884f8a6b5730796a699bdd6066262d3fee18ff0234f535d2fe967b5d26543d2d7b2097e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ On release, entries are moved into `## x.y.z` sections that match the gem versio
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.4.0
9
+
10
+ - Docs: adds a copy/paste "deploy a simple schedule" example and refines README status section.
11
+ - Refactor: extracts `UnitPathUtils` module for shared identifier/path utilities across `UnitWriter`, `UnitDeleter`, `UnitLister`, and `Renderer`.
12
+ - Refactor: adds polymorphic `Trigger::Base` interface with `#systemd_timer_lines` and `#signature` methods for all trigger types.
13
+ - Refactor: splits `CronParser` into focused `FieldParser` and `DowParser` submodules for maintainability.
14
+ - Refactor: implements strategy pattern for `PeriodParser` with dedicated strategies for Duration, String, Symbol, and Array inputs.
15
+ - Refactor: extracts `UnitContentBuilder` from `Renderer` for cleaner separation of unit content generation.
16
+ - Refactor: adds `Validation` module with composable validators (`type`, `positive_integer`, `non_empty_string`, `non_empty_array`, `in_range`).
17
+
18
+ ## 0.3.0
19
+
20
+ - Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
21
+ - Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
22
+ - Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
23
+
8
24
  ## 0.2.1
9
25
 
10
26
  - Removes an unused filtering metadata keyword argument from the schedule DSL.
data/FEATURE_SUMMARY.md CHANGED
@@ -12,13 +12,22 @@ It complements [`CHANGELOG.md`](CHANGELOG.md) by staying high-level and focusing
12
12
 
13
13
  ## Unreleased
14
14
 
15
+ ## 0.3.0
16
+
17
+ - Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
18
+ - Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
19
+ - Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
20
+
15
21
  ## 0.2.1
16
22
 
17
- - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
18
23
  - Removes an unused filtering metadata keyword argument from the schedule DSL.
19
24
 
20
25
  ## 0.2.0
21
26
 
27
+ - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
28
+
29
+ ## 0.1.0
30
+
22
31
  - The `wheneverd` CLI is implemented using Clamp (`--help`, usage errors in `ERROR: ...` format, `--verbose` for details).
23
32
  - The gem includes a small “whenever-like” domain model (interval parsing, durations, triggers, schedules).
24
33
  - The gem can load a Ruby schedule DSL file via `Wheneverd::DSL::Loader.load_file`.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- wheneverd (0.2.0)
4
+ wheneverd (0.3.0)
5
5
  clamp (~> 1.3)
6
6
 
7
7
  GEM
@@ -122,7 +122,7 @@ CHECKSUMS
122
122
  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
123
123
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
124
124
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
125
- wheneverd (0.2.0)
125
+ wheneverd (0.3.0)
126
126
  yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f
127
127
 
128
128
  BUNDLED WITH
data/README.md CHANGED
@@ -4,14 +4,23 @@ Wheneverd is to systemd timers what the [`whenever` gem](https://github.com/java
4
4
 
5
5
  ## Status
6
6
 
7
- Pre-1.0, but working end-to-end for user systemd timers:
7
+ Pre-1.0, but working end-to-end for systemd user timers on Linux:
8
8
 
9
9
  - Loads a Ruby schedule DSL file (default: `config/schedule.rb`).
10
10
  - Renders systemd `.service`/`.timer` units (interval, calendar, and 5-field cron schedules).
11
- - Writes, lists, and deletes generated unit files (default: `~/.config/systemd/user`).
11
+ - Writes, diffs, shows, and deletes generated unit files (default: `~/.config/systemd/user`).
12
12
  - Enables/starts/stops/disables/restarts timers via `systemctl --user`.
13
+ - Validates `OnCalendar=` values with `systemd-analyze` (optional unit verification).
13
14
  - Manages lingering via `loginctl` (so timers can run while logged out).
14
15
 
16
+ Non-goals / not yet implemented:
17
+
18
+ - System-level units (`/etc/systemd/system`) / `systemctl` without `--user`.
19
+ - Non-systemd schedulers (cron, launchd, etc).
20
+ - Non-Linux platforms (no Windows/macOS support).
21
+
22
+ Expect the CLI and generated unit details to change until 1.0.
23
+
15
24
  See `FEATURE_SUMMARY.md` for user-visible behavior, and `CHANGELOG.md` for release notes.
16
25
 
17
26
  ## Installation
@@ -34,6 +43,9 @@ bundle install
34
43
  wheneverd --help
35
44
  wheneverd init
36
45
  wheneverd show
46
+ wheneverd status
47
+ wheneverd diff
48
+ wheneverd validate
37
49
  wheneverd write
38
50
  wheneverd delete
39
51
  wheneverd activate
@@ -43,6 +55,8 @@ wheneverd current
43
55
  wheneverd linger
44
56
  ```
45
57
 
58
+ Use `wheneverd init` to create a starter `config/schedule.rb` template (including examples for `command` and `shell`).
59
+
46
60
  ### Minimal `config/schedule.rb` example
47
61
 
48
62
  ```ruby
@@ -57,6 +71,40 @@ every 1.day, at: "4:30 am" do
57
71
  end
58
72
  ```
59
73
 
74
+ ### Deploy a simple schedule (copy/paste)
75
+
76
+ From your project root (the default identifier is the current directory name):
77
+
78
+ ```bash
79
+ # Install (skip if already in your Gemfile)
80
+ bundle add wheneverd
81
+ bundle install
82
+
83
+ # Write a schedule that appends a timestamp to ~/.cache/wheneverd-demo.log every minute
84
+ mkdir -p config
85
+ cat > config/schedule.rb <<'RUBY'
86
+ # frozen_string_literal: true
87
+
88
+ every "1m" do
89
+ shell "mkdir -p ~/.cache && date >> ~/.cache/wheneverd-demo.log"
90
+ end
91
+ RUBY
92
+
93
+ # Preview, write units, and enable/start the timer(s)
94
+ bundle exec wheneverd show
95
+ bundle exec wheneverd validate
96
+ bundle exec wheneverd write
97
+ bundle exec wheneverd activate
98
+
99
+ # Verify it’s installed and running
100
+ bundle exec wheneverd status
101
+ tail -n 5 ~/.cache/wheneverd-demo.log
102
+
103
+ # Stop/disable timers and remove generated unit files
104
+ bundle exec wheneverd deactivate
105
+ bundle exec wheneverd delete
106
+ ```
107
+
60
108
  Preview the generated units:
61
109
 
62
110
  ```bash
@@ -137,13 +185,26 @@ end
137
185
 
138
186
  ### `command`
139
187
 
140
- `command("...")` appends a oneshot `ExecStart=` job. Commands must be non-empty strings.
188
+ `command(...)` appends a oneshot `ExecStart=` job.
189
+
190
+ Accepted forms:
141
191
 
142
- The command string is inserted into `ExecStart=` as-is (no shell wrapping). If you need shell features
143
- (pipes, redirects, globbing, env var expansion), wrap it yourself, for example:
192
+ - `command("...")` (String): inserted into `ExecStart=` as-is (after stripping surrounding whitespace).
193
+ - `command(["bin", "arg1", "arg2"])` (argv Array): formatted/escaped into a systemd-compatible `ExecStart=` string.
194
+
195
+ If you need shell features (pipes, redirects, globbing, env var expansion), either wrap it yourself, or use `shell`:
144
196
 
145
197
  ```ruby
146
198
  command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
199
+ command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
200
+ ```
201
+
202
+ ### `shell`
203
+
204
+ `shell("...")` is a convenience helper for the common `/bin/bash -lc` pattern:
205
+
206
+ ```ruby
207
+ shell "echo hello | sed -e s/hello/hi/"
147
208
  ```
148
209
 
149
210
  ### `every` periods
@@ -203,11 +264,15 @@ Notes:
203
264
  - Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
204
265
  - `wheneverd write` / `wheneverd reload` prune previously generated units for the identifier by default (use `--no-prune` to keep old units around).
205
266
  - `--unit-dir` controls where unit files are written/read/deleted; `activate`/`deactivate` use systemd’s unit search path.
267
+ - `wheneverd diff` returns exit status `0` when no differences are found, and `1` when differences are found.
206
268
 
207
269
  Commands:
208
270
 
209
271
  - `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
210
272
  - `wheneverd show [--schedule PATH] [--identifier NAME]` prints rendered units to stdout.
273
+ - `wheneverd status [--identifier NAME] [--unit-dir PATH]` prints `systemctl --user list-timers` and `systemctl --user status` for installed timers.
274
+ - `wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]` diffs rendered units vs unit files on disk.
275
+ - `wheneverd validate [--schedule PATH] [--identifier NAME] [--verify]` validates rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
211
276
  - `wheneverd write [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--dry-run] [--[no-]prune]` writes units to disk (or prints paths in `--dry-run` mode).
212
277
  - `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
213
278
  - `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd diff` (rendered output vs files on disk).
5
+ #
6
+ # Exit statuses follow `diff` semantics:
7
+ # - 0: no differences
8
+ # - 1: differences found
9
+ # - 2: error
10
+ class CLI::Diff < CLI
11
+ def execute
12
+ diffs = unit_diffs
13
+ return 0 if diffs.empty?
14
+
15
+ diffs.each_with_index do |diff, idx|
16
+ puts "" if idx.positive?
17
+ puts diff
18
+ end
19
+ 1
20
+ rescue StandardError => e
21
+ handle_error(e)
22
+ 2
23
+ end
24
+
25
+ private
26
+
27
+ def unit_diffs
28
+ rendered = rendered_units_by_basename
29
+ installed = installed_units_by_basename
30
+
31
+ diffs = rendered.map do |basename, contents|
32
+ diff_for_rendered_unit(basename, contents, installed[basename])
33
+ end
34
+ diffs.concat(stale_unit_diffs(rendered: rendered, installed: installed))
35
+ diffs.compact
36
+ end
37
+
38
+ def stale_unit_diffs(rendered:, installed:)
39
+ (installed.keys - rendered.keys).sort.map do |basename|
40
+ diff_for_removed_unit(basename, installed.fetch(basename))
41
+ end
42
+ end
43
+
44
+ def rendered_units_by_basename
45
+ render_units.to_h { |unit| [unit.path_basename, unit.contents.to_s] }
46
+ end
47
+
48
+ def installed_units_by_basename
49
+ paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
50
+ paths.to_h { |path| [File.basename(path), path] }
51
+ end
52
+
53
+ def diff_for_rendered_unit(basename, rendered_contents, installed_path)
54
+ return diff_for_added_unit(basename, rendered_contents) if installed_path.nil?
55
+
56
+ installed_contents = File.read(installed_path)
57
+ return nil if installed_contents == rendered_contents
58
+
59
+ diff_for_changed_unit(basename, installed_path, installed_contents, rendered_contents)
60
+ end
61
+
62
+ def diff_for_added_unit(basename, rendered_contents)
63
+ dest_path = File.join(File.expand_path(unit_dir.to_s), basename)
64
+ header = ["diff --wheneverd #{basename}", "--- /dev/null", "+++ #{dest_path}"]
65
+ (header + line_diff("", rendered_contents)).join("\n")
66
+ end
67
+
68
+ def diff_for_removed_unit(basename, installed_path)
69
+ header = ["diff --wheneverd #{basename}", "--- #{installed_path}", "+++ /dev/null"]
70
+ (header + line_diff(File.read(installed_path), "")).join("\n")
71
+ end
72
+
73
+ def diff_for_changed_unit(basename, installed_path, installed_contents, rendered_contents)
74
+ header = ["diff --wheneverd #{basename}", "--- #{installed_path}",
75
+ "+++ #{basename} (rendered)"]
76
+ (header + line_diff(installed_contents, rendered_contents)).join("\n")
77
+ end
78
+
79
+ def line_diff(old_contents, new_contents)
80
+ old_lines = old_contents.to_s.lines.map(&:chomp)
81
+ new_lines = new_contents.to_s.lines.map(&:chomp)
82
+ max = [old_lines.length, new_lines.length].max
83
+
84
+ (0...max).flat_map do |idx|
85
+ diff_lines_for(old_lines[idx], new_lines[idx])
86
+ end
87
+ end
88
+
89
+ def diff_lines_for(old_line, new_line)
90
+ return [] if old_line.nil? && new_line.nil?
91
+ return [" #{old_line}"] if old_line == new_line
92
+ return ["-#{old_line}", "+#{new_line}"] if old_line && new_line
93
+ return ["-#{old_line}"] if old_line
94
+
95
+ ["+#{new_line}"]
96
+ end
97
+ end
98
+ end
@@ -32,7 +32,7 @@ module Wheneverd
32
32
  end
33
33
 
34
34
  every :hour do
35
- command "echo hourly"
35
+ command ["echo", "hello world"]
36
36
  end
37
37
 
38
38
  every :sunday, at: "12pm" do
@@ -43,6 +43,10 @@ module Wheneverd
43
43
  command "echo midweek"
44
44
  end
45
45
 
46
+ every :day, at: "12:15" do
47
+ shell "echo hello | sed -e s/hello/hi/"
48
+ end
49
+
46
50
  every "0 0 27-31 * *" do
47
51
  command "echo raw_cron"
48
52
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd status` (show installed timer status via `systemctl --user`).
5
+ class CLI::Status < CLI
6
+ def execute
7
+ timer_units = installed_timer_unit_basenames
8
+ return 0 if timer_units.empty?
9
+
10
+ print_list_timers(timer_units)
11
+ print_status(timer_units)
12
+ 0
13
+ rescue StandardError => e
14
+ handle_error(e)
15
+ end
16
+
17
+ private
18
+
19
+ # @return [Array<String>]
20
+ def installed_timer_unit_basenames
21
+ paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
22
+ paths.map { |path| File.basename(path) }.grep(/\.timer\z/).uniq
23
+ end
24
+
25
+ def print_list_timers(timer_units)
26
+ stdout, = Wheneverd::Systemd::Systemctl.run("list-timers", "--all", *timer_units)
27
+ print stdout
28
+ end
29
+
30
+ def print_status(timer_units)
31
+ timer_units.each do |unit|
32
+ stdout, = Wheneverd::Systemd::Systemctl.run("status", unit)
33
+ print stdout
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Wheneverd
6
+ # Implements `wheneverd validate` (validate rendered OnCalendar values and unit files).
7
+ class CLI::Validate < CLI
8
+ option "--verify", :flag,
9
+ "Also run systemd-analyze --user verify (writes units to a temporary directory)"
10
+
11
+ def execute
12
+ units = render_units
13
+ validate_on_calendar(units)
14
+ validate_units(units) if verify?
15
+ 0
16
+ rescue StandardError => e
17
+ handle_error(e)
18
+ end
19
+
20
+ private
21
+
22
+ def validate_on_calendar(units)
23
+ values = on_calendar_values(units)
24
+ if values.empty?
25
+ puts "No OnCalendar= values found" if verbose?
26
+ return
27
+ end
28
+
29
+ values.each do |value|
30
+ Wheneverd::Systemd::Analyze.calendar(value)
31
+ puts "OK OnCalendar=#{value}" if verbose?
32
+ end
33
+ end
34
+
35
+ def on_calendar_values(units)
36
+ units.select { |unit| unit.kind == :timer }
37
+ .flat_map { |timer| timer.contents.to_s.lines.grep(/\AOnCalendar=/) }
38
+ .map { |line| line.delete_prefix("OnCalendar=").strip }
39
+ .uniq
40
+ end
41
+
42
+ def validate_units(units)
43
+ Dir.mktmpdir("wheneverd-validate-") do |dir|
44
+ paths = Wheneverd::Systemd::UnitWriter.write(units, unit_dir: dir, prune: false)
45
+ Wheneverd::Systemd::Analyze.verify(paths, user: true)
46
+ puts "OK systemd-analyze --user verify" if verbose?
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/wheneverd/cli.rb CHANGED
@@ -63,6 +63,9 @@ end
63
63
  require_relative "cli/help"
64
64
  require_relative "cli/init"
65
65
  require_relative "cli/show"
66
+ require_relative "cli/status"
67
+ require_relative "cli/diff"
68
+ require_relative "cli/validate"
66
69
  require_relative "cli/write"
67
70
  require_relative "cli/delete"
68
71
  require_relative "cli/activate"
@@ -78,6 +81,9 @@ module Wheneverd
78
81
  subcommand "help", "Show help", Wheneverd::CLI::Help
79
82
  subcommand "init", "Create a schedule template", Wheneverd::CLI::Init
80
83
  subcommand "show", "Render units to stdout", Wheneverd::CLI::Show
84
+ subcommand "status", "Show systemctl list-timers + status for this identifier", Wheneverd::CLI::Status
85
+ subcommand "diff", "Diff rendered units vs files on disk", Wheneverd::CLI::Diff
86
+ subcommand "validate", "Validate schedule via systemd-analyze", Wheneverd::CLI::Validate
81
87
  subcommand "write", "Write units to disk", Wheneverd::CLI::Write
82
88
  subcommand "delete", "Delete units from disk", Wheneverd::CLI::Delete
83
89
  subcommand "activate", "Enable and start timers via systemctl --user", Wheneverd::CLI::Activate
@@ -5,7 +5,7 @@ module Wheneverd
5
5
  # The evaluation context used for schedule files.
6
6
  #
7
7
  # The schedule file is evaluated via `instance_eval`, so methods defined here become available
8
- # as the schedule DSL (`every`, `command`).
8
+ # as the schedule DSL (`every`, `command`, `shell`).
9
9
  class Context
10
10
  # @return [String] absolute schedule path
11
11
  attr_reader :path
@@ -44,21 +44,64 @@ module Wheneverd
44
44
 
45
45
  # Add a oneshot command job to the current `every` entry.
46
46
  #
47
- # @param command_str [String]
47
+ # @example String command
48
+ # command "echo hello"
49
+ #
50
+ # @example argv command
51
+ # command ["echo", "hello world"]
52
+ #
53
+ # @param command_value [String, Array<String>]
48
54
  # @return [void]
49
- def command(command_str)
50
- unless @current_entry
51
- raise LoadError.new("command() must be called inside every() block",
52
- path: path)
53
- end
55
+ def command(command_value)
56
+ ensure_in_every_block!("command")
54
57
 
55
- @current_entry.add_job(Wheneverd::Job::Command.new(command: command_str))
58
+ @current_entry.add_job(Wheneverd::Job::Command.new(command: command_value))
56
59
  rescue Wheneverd::InvalidCommandError => e
57
60
  raise LoadError.new(e.message, path: path)
58
61
  end
59
62
 
63
+ # Add a oneshot command job that runs via `/bin/bash -lc`.
64
+ #
65
+ # @example
66
+ # shell "echo hello | sed -e s/hello/hi/"
67
+ #
68
+ # @param script [String] non-empty script to pass as `bash -lc <script>`
69
+ # @param shell [String] shell executable (default: "/bin/bash")
70
+ # @return [void]
71
+ def shell(script, shell: "/bin/bash")
72
+ ensure_in_every_block!("shell")
73
+ script_stripped = normalize_shell_script(script)
74
+ shell_executable = normalize_shell_executable(shell)
75
+ command([shell_executable, "-lc", script_stripped])
76
+ end
77
+
60
78
  private
61
79
 
80
+ def ensure_in_every_block!(name)
81
+ return if @current_entry
82
+
83
+ raise LoadError.new("#{name}() must be called inside every() block", path: path)
84
+ end
85
+
86
+ def normalize_shell_script(script)
87
+ unless script.is_a?(String)
88
+ raise LoadError.new("shell() script must be a String (got #{script.class})",
89
+ path: path)
90
+ end
91
+
92
+ stripped = script.strip
93
+ raise LoadError.new("shell() script must not be empty", path: path) if stripped.empty?
94
+
95
+ stripped
96
+ end
97
+
98
+ def normalize_shell_executable(shell)
99
+ stripped = shell.to_s.strip
100
+ raise LoadError.new("shell() shell must not be empty", path: path) if stripped.empty?
101
+
102
+ stripped
103
+ end
104
+
62
105
  def with_current_entry(entry)
63
106
  previous_entry = @current_entry
64
107
  @current_entry = entry
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "calendar_symbol_period_list"
3
+ require_relative "period_strategy"
4
4
 
5
5
  module Wheneverd
6
6
  module DSL
@@ -8,24 +8,12 @@ module Wheneverd
8
8
  #
9
9
  # Supported period forms are described in the README.
10
10
  #
11
- # Notes:
12
- #
13
- # - Interval strings and {Wheneverd::Duration} values produce monotonic triggers
14
- # ({Wheneverd::Trigger::Interval}).
15
- # - Calendar symbol periods and cron strings produce calendar triggers
16
- # ({Wheneverd::Trigger::Calendar}).
17
- # - The `at:` option is only valid for calendar triggers, with a convenience exception for
18
- # `every 1.day, at: ...` which is treated as a daily calendar trigger.
11
+ # Uses a strategy pattern to delegate parsing to specialized strategy classes:
12
+ # - {PeriodStrategy::DurationStrategy} for Duration values
13
+ # - {PeriodStrategy::StringStrategy} for interval strings and cron expressions
14
+ # - {PeriodStrategy::SymbolStrategy} for calendar symbols
15
+ # - {PeriodStrategy::ArrayStrategy} for arrays of calendar symbols
19
16
  class PeriodParser
20
- DAY_SECONDS = 60 * 60 * 24
21
- REBOOT_NOT_SUPPORTED_MESSAGE =
22
- "The :reboot period is not supported; use an interval or calendar period instead"
23
-
24
- CALENDAR_SYMBOLS = %i[
25
- hour day month year weekday weekend
26
- monday tuesday wednesday thursday friday saturday sunday
27
- ].freeze
28
-
29
17
  attr_reader :path
30
18
 
31
19
  # @param path [String] schedule path for error reporting
@@ -38,98 +26,11 @@ module Wheneverd
38
26
  # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
39
27
  def trigger_for(period, at:)
40
28
  at_times = AtNormalizer.normalize(at, path: path)
41
- trigger_for_period(period, at_times: at_times)
29
+ strategy = PeriodStrategy.for(period, path: path)
30
+ strategy.parse(period, at_times: at_times)
42
31
  rescue Wheneverd::InvalidIntervalError => e
43
32
  raise InvalidPeriodError.new(e.message, path: path)
44
33
  end
45
-
46
- private
47
-
48
- def trigger_for_period(period, at_times:)
49
- return duration_trigger_for(period, at_times: at_times) if period.is_a?(Wheneverd::Duration)
50
- return array_trigger_for(period, at_times: at_times) if period.is_a?(Array)
51
- return string_trigger_for(period, at_times: at_times) if period.is_a?(String)
52
- return symbol_trigger_for(period, at_times: at_times) if period.is_a?(Symbol)
53
-
54
- raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
55
- end
56
-
57
- def duration_trigger_for(duration, at_times:)
58
- if at_times.any?
59
- return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
60
-
61
- raise InvalidPeriodError.new("at: is only supported with calendar periods", path: path)
62
- end
63
-
64
- Wheneverd::Trigger::Interval.new(seconds: duration.to_i)
65
- end
66
-
67
- def daily_calendar_trigger(at_times)
68
- Wheneverd::Trigger::Calendar.new(on_calendar: build_calendar_specs("day", at_times))
69
- end
70
-
71
- def string_trigger_for(str, at_times:)
72
- period_str = str.strip
73
-
74
- return interval_trigger(period_str, at_times: at_times) if interval_string?(period_str)
75
-
76
- return cron_trigger(period_str, at_times: at_times) if cron_string?(period_str)
77
-
78
- raise InvalidPeriodError.new("Unrecognized period #{period_str.inspect}", path: path)
79
- end
80
-
81
- def interval_trigger(period_str, at_times:)
82
- if at_times.any?
83
- raise InvalidPeriodError.new("at: is not supported for interval periods", path: path)
84
- end
85
-
86
- seconds = Wheneverd::Interval.parse(period_str)
87
- Wheneverd::Trigger::Interval.new(seconds: seconds)
88
- end
89
-
90
- def cron_trigger(period_str, at_times:)
91
- if at_times.any?
92
- raise InvalidPeriodError.new("at: is not supported for cron periods", path: path)
93
- end
94
-
95
- Wheneverd::Trigger::Calendar.new(on_calendar: ["cron:#{period_str}"])
96
- end
97
-
98
- def symbol_trigger_for(sym, at_times:)
99
- raise InvalidPeriodError.new(REBOOT_NOT_SUPPORTED_MESSAGE, path: path) if sym == :reboot
100
-
101
- if CALENDAR_SYMBOLS.include?(sym)
102
- return Wheneverd::Trigger::Calendar.new(
103
- on_calendar: build_calendar_specs(sym.to_s, at_times)
104
- )
105
- end
106
-
107
- raise InvalidPeriodError.new("Unknown period symbol: #{sym.inspect}", path: path)
108
- end
109
-
110
- def array_trigger_for(periods, at_times:)
111
- bases = CalendarSymbolPeriodList.validate(
112
- periods,
113
- allowed_symbols: CALENDAR_SYMBOLS,
114
- path: path
115
- ).map(&:to_s)
116
- specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
117
- Wheneverd::Trigger::Calendar.new(on_calendar: specs)
118
- end
119
-
120
- def build_calendar_specs(base, at_times)
121
- return [base] if at_times.empty?
122
-
123
- at_times.map { |t| "#{base}@#{t}" }
124
- end
125
-
126
- def interval_string?(str)
127
- /\A-?\d+[smhdw]\z/.match?(str)
128
- end
129
-
130
- def cron_string?(str)
131
- str.split(/\s+/).length == 5
132
- end
133
34
  end
134
35
  end
135
36
  end