wheneverd 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48c6bdb7f18c775a63b874f0509d276f9f5a9b585193aa9f3b1795f3374f26c8
4
- data.tar.gz: ef19f548714b7c70998e7e53ee560876742108199f8418e342a7c2f7117fe796
3
+ metadata.gz: c4d813e1e0ad0b44e738d4ba42767b5fb490dcaf5a7883328c91faa2ab726305
4
+ data.tar.gz: 1185929834d9f12adb4afd8a1bc2d1a4a366d99b260afe1d075a1ca58dc6dc26
5
5
  SHA512:
6
- metadata.gz: 5e19a91cc2c5ad5ecd4d040cdc450de1153a8f685ef1c8be7db022d6f4189f628d8c9effe52d2a3428734a4de088e5eb1b4fba5d7d1a655fb9e38b6110794d60
7
- data.tar.gz: 027fabc2a861d96f3f79afe6930d18758aaeaec2e39490ab93a8b7b0939a5ff543de0f6ac06438ce6ac100b68a4dd8375d0dcedf104d82f90618368bd82c7621
6
+ metadata.gz: ed43947461d6e8dadf5825c8075e08816535c42202a9764c7b57d1fb4a7948a0dbfe1ff57e13cc710a853efa6910f802b93774b239736ed30bcec145812c08fc
7
+ data.tar.gz: 4406adc4f70afde1953cd8ea82979d2c37bf6b096232930724f2537b04d5ae187437fafa29215163ebd985eb132b89c7ee990497ba2cdb70617166b5d88114ee
data/CHANGELOG.md CHANGED
@@ -5,10 +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
- - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
8
+ ## 0.3.0
9
+
10
+ - Schedule DSL: `command` accepts argv arrays, adds a `shell` helper for `/bin/bash -lc`, and `wheneverd init` includes examples.
11
+ - Adds `wheneverd status` (show `systemctl --user list-timers` + `systemctl --user status` for installed timers) and `wheneverd diff` (diff rendered units vs files on disk).
12
+ - Adds `wheneverd validate` to validate rendered `OnCalendar=` values via `systemd-analyze calendar` (and with `--verify`, runs `systemd-analyze --user verify` on temporary unit files).
13
+
14
+ ## 0.2.1
15
+
16
+ - Removes an unused filtering metadata keyword argument from the schedule DSL.
9
17
 
10
18
  ## 0.2.0
11
19
 
20
+ - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
21
+
22
+ ## 0.1.0
23
+
12
24
  - Provides a Clamp-based `wheneverd` CLI with `--help`, `--version`, and `--verbose` (usage errors in `ERROR: ...` format).
13
25
  - Adds core domain objects and helpers for building schedules (interval parsing, durations, triggers, entries, jobs).
14
26
  - Adds a Ruby DSL loader (`Wheneverd::DSL::Loader.load_file`) supporting `every(...)` blocks with `command(...)` jobs.
data/FEATURE_SUMMARY.md CHANGED
@@ -12,19 +12,30 @@ It complements [`CHANGELOG.md`](CHANGELOG.md) by staying high-level and focusing
12
12
 
13
13
  ## Unreleased
14
14
 
15
- - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
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
+
21
+ ## 0.2.1
22
+
23
+ - Removes an unused filtering metadata keyword argument from the schedule DSL.
16
24
 
17
25
  ## 0.2.0
18
26
 
27
+ - Adds `wheneverd linger enable|disable|status` for managing systemd user lingering via `loginctl`.
28
+
29
+ ## 0.1.0
30
+
19
31
  - The `wheneverd` CLI is implemented using Clamp (`--help`, usage errors in `ERROR: ...` format, `--verbose` for details).
20
32
  - The gem includes a small “whenever-like” domain model (interval parsing, durations, triggers, schedules).
21
33
  - The gem can load a Ruby schedule DSL file via `Wheneverd::DSL::Loader.load_file`.
22
- - Schedule DSL supports `every(period, at: ..., roles: ...) { command("...") }` entries (multiple `command` calls per entry).
34
+ - Schedule DSL supports `every(period, at: ...) { command("...") }` entries (multiple `command` calls per entry).
23
35
  - Schedule DSL supports multiple calendar period symbols per `every` block (e.g. `every :tuesday, :wednesday`).
24
36
  - Supported `every` periods include interval strings/durations, calendar shortcuts (`:hour`, `:day`, `:month`, `:year`),
25
37
  day selectors (`:monday..:sunday`, `:weekday`, `:weekend`), and standard 5-field cron strings.
26
38
  - `at:` supports a string or an array of strings (for calendar schedules), like `"4:30 am"` or `"00:15"`.
27
- - `roles:` is accepted and stored on entries, but is not used for filtering yet.
28
39
  - The gem can render systemd `.service` and `.timer` units via `Wheneverd::Systemd::Renderer.render`.
29
40
  - Generated unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
30
41
  - Interval timers include both `OnActiveSec=` and `OnUnitActiveSec=` to ensure a newly started timer has a next run scheduled.
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
@@ -2,15 +2,17 @@
2
2
 
3
3
  Wheneverd is to systemd timers what the [`whenever` gem](https://github.com/javan/whenever) is to cron.
4
4
 
5
- Tagline / repo: `git@github.com:bigcurl/wheneverd.git`
6
-
7
5
  ## Status
8
6
 
9
- Working end-to-end: schedule DSL loading, systemd unit rendering, and safe unit write/list/delete are implemented, along with a CLI for `init`, `show`, `write`, `delete`, `activate`, `deactivate`, `reload`, and `current`.
7
+ Pre-1.0, but working end-to-end for user systemd timers:
10
8
 
11
- Known limitations: `roles:` is accepted but not used for filtering yet.
9
+ - Loads a Ruby schedule DSL file (default: `config/schedule.rb`).
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`).
12
+ - Enables/starts/stops/disables/restarts timers via `systemctl --user`.
13
+ - Manages lingering via `loginctl` (so timers can run while logged out).
12
14
 
13
- See `FEATURE_SUMMARY.md` for high-level user-visible behavior, and `CHANGELOG.md` for release notes.
15
+ See `FEATURE_SUMMARY.md` for user-visible behavior, and `CHANGELOG.md` for release notes.
14
16
 
15
17
  ## Installation
16
18
 
@@ -32,15 +34,20 @@ bundle install
32
34
  wheneverd --help
33
35
  wheneverd init
34
36
  wheneverd show
37
+ wheneverd status
38
+ wheneverd diff
39
+ wheneverd validate
35
40
  wheneverd write
36
41
  wheneverd delete
37
42
  wheneverd activate
38
43
  wheneverd deactivate
39
44
  wheneverd reload
40
45
  wheneverd current
41
- wheneverd linger status
46
+ wheneverd linger
42
47
  ```
43
48
 
49
+ Use `wheneverd init` to create a starter `config/schedule.rb` template (including examples for `command` and `shell`).
50
+
44
51
  ### Minimal `config/schedule.rb` example
45
52
 
46
53
  ```ruby
@@ -120,7 +127,7 @@ Note: schedule files are executed as Ruby. Do not run untrusted schedule code.
120
127
  The core shape is:
121
128
 
122
129
  ```ruby
123
- every(period, at: nil, roles: nil) do
130
+ every(period, at: nil) do
124
131
  command "echo hello"
125
132
  end
126
133
  ```
@@ -135,13 +142,26 @@ end
135
142
 
136
143
  ### `command`
137
144
 
138
- `command("...")` appends a oneshot `ExecStart=` job. Commands must be non-empty strings.
145
+ `command(...)` appends a oneshot `ExecStart=` job.
139
146
 
140
- The command string is inserted into `ExecStart=` as-is (no shell wrapping). If you need shell features
141
- (pipes, redirects, globbing, env var expansion), wrap it yourself, for example:
147
+ Accepted forms:
148
+
149
+ - `command("...")` (String): inserted into `ExecStart=` as-is (after stripping surrounding whitespace).
150
+ - `command(["bin", "arg1", "arg2"])` (argv Array): formatted/escaped into a systemd-compatible `ExecStart=` string.
151
+
152
+ If you need shell features (pipes, redirects, globbing, env var expansion), either wrap it yourself, or use `shell`:
142
153
 
143
154
  ```ruby
144
155
  command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
156
+ command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
157
+ ```
158
+
159
+ ### `shell`
160
+
161
+ `shell("...")` is a convenience helper for the common `/bin/bash -lc` pattern:
162
+
163
+ ```ruby
164
+ shell "echo hello | sed -e s/hello/hi/"
145
165
  ```
146
166
 
147
167
  ### `every` periods
@@ -185,10 +205,6 @@ Cron translation supports standard 5-field crontab strings (`minute hour day-of-
185
205
 
186
206
  Unsupported cron patterns raise an error at render time (e.g. non-5-field strings, `@daily`, `L`, `W`, `#`, `?`).
187
207
 
188
- ### `roles:`
189
-
190
- `roles:` is accepted and stored on entries, but is ignored in v1 (no role-based filtering yet).
191
-
192
208
  ## CLI
193
209
 
194
210
  Defaults:
@@ -204,17 +220,23 @@ Notes:
204
220
  - Identifiers are sanitized for use in unit file names (non-alphanumeric characters become `-`).
205
221
  - Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
206
222
  - `wheneverd write` / `wheneverd reload` prune previously generated units for the identifier by default (use `--no-prune` to keep old units around).
223
+ - `--unit-dir` controls where unit files are written/read/deleted; `activate`/`deactivate` use systemd’s unit search path.
224
+ - `wheneverd diff` returns exit status `0` when no differences are found, and `1` when differences are found.
207
225
 
208
226
  Commands:
209
227
 
210
228
  - `wheneverd init [--schedule PATH] [--force]` writes a template schedule file.
211
229
  - `wheneverd show [--schedule PATH] [--identifier NAME]` prints rendered units to stdout.
212
- - `wheneverd write [--dry-run] [--unit-dir PATH] [--[no-]prune]` writes units to disk (or prints paths in `--dry-run` mode).
213
- - `wheneverd delete [--dry-run] [--unit-dir PATH]` deletes previously generated units for the identifier.
230
+ - `wheneverd status [--identifier NAME] [--unit-dir PATH]` prints `systemctl --user list-timers` and `systemctl --user status` for installed timers.
231
+ - `wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]` diffs rendered units vs unit files on disk.
232
+ - `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).
233
+ - `wheneverd write [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--dry-run] [--[no-]prune]` writes units to disk (or prints paths in `--dry-run` mode).
234
+ - `wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]` deletes previously generated units for the identifier.
214
235
  - `wheneverd activate [--schedule PATH] [--identifier NAME]` runs `systemctl --user daemon-reload` and enables/starts the timers.
215
236
  - `wheneverd deactivate [--schedule PATH] [--identifier NAME]` stops and disables the timers.
216
237
  - `wheneverd reload [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--[no-]prune]` writes units, reloads systemd, and restarts timers.
217
238
  - `wheneverd current [--identifier NAME] [--unit-dir PATH]` prints the currently installed unit file contents from disk.
239
+ - `wheneverd linger [--user NAME] [enable|disable|status]` manages lingering via `loginctl` (`status` is the default).
218
240
 
219
241
  ## Development
220
242
 
@@ -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
@@ -25,16 +25,15 @@ module Wheneverd
25
25
  #
26
26
  # @param periods [Array<String, Symbol, Wheneverd::Duration, Array<Symbol>>]
27
27
  # @param at [String, Array<String>, nil]
28
- # @param roles [Object] stored but currently not used for filtering
29
28
  # @return [Wheneverd::Entry]
30
- def every(*periods, at: nil, roles: nil, &block)
29
+ def every(*periods, at: nil, &block)
31
30
  raise InvalidPeriodError.new("every() requires a block", path: path) unless block
32
31
 
33
32
  raise InvalidPeriodError.new("every() requires a period", path: path) if periods.empty?
34
33
 
35
34
  period = periods.length == 1 ? periods.first : periods
36
35
  trigger = @period_parser.trigger_for(period, at: at)
37
- entry = Wheneverd::Entry.new(trigger: trigger, roles: roles)
36
+ entry = Wheneverd::Entry.new(trigger: trigger)
38
37
 
39
38
  schedule.add_entry(entry)
40
39
 
@@ -45,21 +44,64 @@ module Wheneverd
45
44
 
46
45
  # Add a oneshot command job to the current `every` entry.
47
46
  #
48
- # @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>]
49
54
  # @return [void]
50
- def command(command_str)
51
- unless @current_entry
52
- raise LoadError.new("command() must be called inside every() block",
53
- path: path)
54
- end
55
+ def command(command_value)
56
+ ensure_in_every_block!("command")
55
57
 
56
- @current_entry.add_job(Wheneverd::Job::Command.new(command: command_str))
58
+ @current_entry.add_job(Wheneverd::Job::Command.new(command: command_value))
57
59
  rescue Wheneverd::InvalidCommandError => e
58
60
  raise LoadError.new(e.message, path: path)
59
61
  end
60
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
+
61
78
  private
62
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
+
63
105
  def with_current_entry(entry)
64
106
  previous_entry = @current_entry
65
107
  @current_entry = entry
@@ -6,17 +6,15 @@ module Wheneverd
6
6
  # An entry ties together a trigger (when to run) and one or more jobs (what to run).
7
7
  class Entry
8
8
  # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar, Wheneverd::Trigger::Boot]
9
- attr_reader :trigger, :jobs, :roles
9
+ attr_reader :trigger, :jobs
10
10
 
11
11
  # @param trigger [Object] a trigger object describing when to run
12
12
  # @param jobs [Array<Object>] job objects (usually {Wheneverd::Job::Command})
13
- # @param roles [Object] stored but currently not used for filtering
14
- def initialize(trigger:, jobs: [], roles: nil)
13
+ def initialize(trigger:, jobs: [])
15
14
  raise ArgumentError, "trigger is required" if trigger.nil?
16
15
 
17
16
  @trigger = trigger
18
17
  @jobs = jobs.dup
19
- @roles = roles
20
18
  end
21
19
 
22
20
  # Append a job to the entry.
@@ -4,25 +4,105 @@ module Wheneverd
4
4
  module Job
5
5
  # A oneshot command job rendered as `ExecStart=` in a `systemd` service unit.
6
6
  #
7
- # Note that the command is inserted into `ExecStart=` as-is. If you need shell features like
8
- # pipes, redirects, or environment variable expansion, wrap the command explicitly:
7
+ # This job accepts either:
8
+ #
9
+ # - A String (inserted into `ExecStart=` as-is, after stripping surrounding whitespace), or
10
+ # - An argv Array (formatted/escaped into a systemd-compatible `ExecStart=` string).
11
+ #
12
+ # If you need shell features like pipes, redirects, or environment variable expansion, wrap
13
+ # the command explicitly:
9
14
  #
10
15
  # @example
11
16
  # command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
17
+ #
18
+ # @example argv form (safer argument handling)
19
+ # command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
12
20
  class Command
21
+ SAFE_UNQUOTED = %r{\A[A-Za-z0-9_@%+=:,./-]+\z}.freeze
22
+
23
+ # Rendered `ExecStart=` value.
24
+ #
13
25
  # @return [String]
14
26
  attr_reader :command
15
27
 
16
- # @param command [String] non-empty command to run
28
+ # Original argv form (when constructed with an Array).
29
+ #
30
+ # @return [Array<String>, nil]
31
+ attr_reader :argv
32
+
33
+ # Stable signature used for unit naming.
34
+ #
35
+ # @return [String]
36
+ attr_reader :signature
37
+
38
+ # @param command [String, Array<String>] non-empty command to run
17
39
  def initialize(command:)
18
- unless command.is_a?(String)
19
- raise InvalidCommandError, "Command must be a String (got #{command.class})"
40
+ @argv = nil
41
+ @signature = nil
42
+ @command = nil
43
+
44
+ case command
45
+ when String then init_string(command)
46
+ when Array then init_argv(command)
47
+ else
48
+ raise InvalidCommandError,
49
+ "Command must be a String or an Array (got #{command.class})"
20
50
  end
51
+ end
52
+
53
+ private
54
+
55
+ def init_string(command)
56
+ stripped = command.strip
57
+ raise InvalidCommandError, "Command must not be empty" if stripped.empty?
58
+
59
+ @command = stripped
60
+ @signature = "command:#{@command}"
61
+ end
62
+
63
+ def init_argv(argv)
64
+ normalized = normalize_argv(argv)
65
+ @argv = normalized
66
+ @command = format_execstart(normalized)
67
+ @signature = ["command:argv", normalized.join("\n")].join("\n")
68
+ end
21
69
 
22
- command_stripped = command.strip
23
- raise InvalidCommandError, "Command must not be empty" if command_stripped.empty?
70
+ def normalize_argv(argv)
71
+ raise InvalidCommandError, "Command argv must not be empty" if argv.empty?
72
+
73
+ elements = argv.map { |arg| validate_argv_element(arg) }
74
+ elements[0] = elements[0].strip
75
+ raise InvalidCommandError, "Command argv executable must not be empty" if elements[0].empty?
76
+
77
+ elements
78
+ end
79
+
80
+ def validate_argv_element(arg)
81
+ unless arg.is_a?(String)
82
+ raise InvalidCommandError, "Command argv elements must be Strings (got #{arg.class})"
83
+ end
84
+
85
+ if arg.match?(/[\0\r\n]/)
86
+ raise InvalidCommandError,
87
+ "Command argv elements must not include NUL or newlines"
88
+ end
89
+
90
+ arg
91
+ end
92
+
93
+ def format_execstart(argv)
94
+ argv.map { |arg| format_exec_arg(arg) }.join(" ")
95
+ end
96
+
97
+ def format_exec_arg(arg)
98
+ return "\"\"" if arg.empty?
99
+ return arg if SAFE_UNQUOTED.match?(arg)
100
+
101
+ "\"#{escape_exec_arg(arg)}\""
102
+ end
24
103
 
25
- @command = command_stripped
104
+ def escape_exec_arg(arg)
105
+ arg.gsub(/[\\"]/) { |m| "\\#{m}" }
26
106
  end
27
107
  end
28
108
  end