wheneverd 0.1.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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rubocop.yml +41 -0
  4. data/.yardopts +8 -0
  5. data/AGENTS.md +42 -0
  6. data/CHANGELOG.md +28 -0
  7. data/FEATURE_SUMMARY.md +38 -0
  8. data/Gemfile +16 -0
  9. data/Gemfile.lock +129 -0
  10. data/LICENSE +21 -0
  11. data/README.md +204 -0
  12. data/Rakefile +196 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +5 -0
  15. data/exe/wheneverd +9 -0
  16. data/lib/wheneverd/cli/activate.rb +19 -0
  17. data/lib/wheneverd/cli/current.rb +22 -0
  18. data/lib/wheneverd/cli/deactivate.rb +19 -0
  19. data/lib/wheneverd/cli/delete.rb +20 -0
  20. data/lib/wheneverd/cli/help.rb +18 -0
  21. data/lib/wheneverd/cli/init.rb +78 -0
  22. data/lib/wheneverd/cli/reload.rb +40 -0
  23. data/lib/wheneverd/cli/show.rb +23 -0
  24. data/lib/wheneverd/cli/write.rb +32 -0
  25. data/lib/wheneverd/cli.rb +87 -0
  26. data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
  27. data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
  28. data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
  29. data/lib/wheneverd/dsl/context.rb +72 -0
  30. data/lib/wheneverd/dsl/errors.rb +29 -0
  31. data/lib/wheneverd/dsl/loader.rb +49 -0
  32. data/lib/wheneverd/dsl/period_parser.rb +135 -0
  33. data/lib/wheneverd/duration.rb +27 -0
  34. data/lib/wheneverd/entry.rb +31 -0
  35. data/lib/wheneverd/errors.rb +9 -0
  36. data/lib/wheneverd/interval.rb +37 -0
  37. data/lib/wheneverd/job/command.rb +29 -0
  38. data/lib/wheneverd/schedule.rb +25 -0
  39. data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
  40. data/lib/wheneverd/systemd/cron_parser.rb +352 -0
  41. data/lib/wheneverd/systemd/errors.rb +23 -0
  42. data/lib/wheneverd/systemd/renderer.rb +153 -0
  43. data/lib/wheneverd/systemd/systemctl.rb +38 -0
  44. data/lib/wheneverd/systemd/time_parser.rb +75 -0
  45. data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
  46. data/lib/wheneverd/systemd/unit_lister.rb +59 -0
  47. data/lib/wheneverd/systemd/unit_namer.rb +69 -0
  48. data/lib/wheneverd/systemd/unit_writer.rb +132 -0
  49. data/lib/wheneverd/trigger/boot.rb +26 -0
  50. data/lib/wheneverd/trigger/calendar.rb +26 -0
  51. data/lib/wheneverd/trigger/interval.rb +30 -0
  52. data/lib/wheneverd/version.rb +6 -0
  53. data/lib/wheneverd.rb +41 -0
  54. data/test/cli_activate_test.rb +110 -0
  55. data/test/cli_current_test.rb +94 -0
  56. data/test/cli_deactivate_test.rb +111 -0
  57. data/test/cli_end_to_end_test.rb +98 -0
  58. data/test/cli_reload_test.rb +132 -0
  59. data/test/cli_systemctl_integration_test.rb +76 -0
  60. data/test/cli_systemd_analyze_test.rb +64 -0
  61. data/test/cli_test.rb +332 -0
  62. data/test/domain_model_test.rb +108 -0
  63. data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
  64. data/test/dsl_loader_test.rb +384 -0
  65. data/test/support/cli_subprocess_test_helpers.rb +38 -0
  66. data/test/support/cli_test_helpers.rb +114 -0
  67. data/test/systemd_calendar_spec_test.rb +45 -0
  68. data/test/systemd_cron_parser_test.rb +114 -0
  69. data/test/systemd_renderer_errors_test.rb +85 -0
  70. data/test/systemd_renderer_test.rb +161 -0
  71. data/test/systemd_systemctl_test.rb +46 -0
  72. data/test/systemd_time_parser_test.rb +25 -0
  73. data/test/systemd_unit_deleter_test.rb +83 -0
  74. data/test/systemd_unit_writer_prune_test.rb +85 -0
  75. data/test/systemd_unit_writer_test.rb +71 -0
  76. data/test/test_helper.rb +34 -0
  77. data/test/wheneverd_test.rb +9 -0
  78. data/wheneverd.gemspec +35 -0
  79. metadata +136 -0
data/Rakefile ADDED
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "bundler/setup"
5
+ BUNDLER_SETUP_AVAILABLE = true
6
+ rescue LoadError
7
+ BUNDLER_SETUP_AVAILABLE = false
8
+ end
9
+
10
+ require "json"
11
+ require "open3"
12
+ require "rake/testtask"
13
+
14
+ begin
15
+ require "yard"
16
+ require "yard/rake/yardoc_task"
17
+ YARD_AVAILABLE = true
18
+ rescue LoadError
19
+ YARD_AVAILABLE = false
20
+ end
21
+
22
+ Rake::TestTask.new do |t|
23
+ t.libs << "lib"
24
+ t.pattern = "test/**/*_test.rb"
25
+ t.ruby_opts << "-rbundler/setup" if BUNDLER_SETUP_AVAILABLE
26
+ end
27
+
28
+ task default: :ci
29
+
30
+ def rubocop_corrected_count(output)
31
+ output.scan(/(\d+)\s+offenses?\s+corrected\b/i).flatten.sum(&:to_i)
32
+ end
33
+
34
+ def run_and_echo(command)
35
+ output, status = Open3.capture2e(command)
36
+ $stdout.print(output)
37
+ [output, status]
38
+ end
39
+
40
+ def announce(message)
41
+ puts("\n==> #{message}")
42
+ end
43
+
44
+ CI_STATE = {
45
+ rubocop: { ran: false, reran: false, corrected: 0, success: nil },
46
+ test: { ran: false, success: nil },
47
+ coverage: { line: nil, minimum: nil }
48
+ }.freeze
49
+
50
+ def record_ci(section, key, value)
51
+ CI_STATE.fetch(section)[key] = value
52
+ end
53
+
54
+ def ci_status_label(value)
55
+ case value
56
+ when true
57
+ "OK"
58
+ when false
59
+ "FAILED"
60
+ else
61
+ "SKIPPED"
62
+ end
63
+ end
64
+
65
+ def read_line_coverage
66
+ coverage_path = File.expand_path("coverage/.last_run.json", __dir__)
67
+ return nil unless File.exist?(coverage_path)
68
+
69
+ JSON.parse(File.read(coverage_path)).dig("result", "line")
70
+ rescue JSON::ParserError
71
+ nil
72
+ end
73
+
74
+ def print_ci_rubocop_summary
75
+ rubocop = CI_STATE.fetch(:rubocop)
76
+ details = []
77
+ details << "corrected #{rubocop[:corrected]}" if rubocop[:ran]
78
+ details << "reran" if rubocop[:reran]
79
+ suffix = details.any? ? " (#{details.join(', ')})" : ""
80
+ puts("RuboCop: #{ci_status_label(rubocop[:success])}#{suffix}")
81
+ end
82
+
83
+ def print_ci_test_summary
84
+ test = CI_STATE.fetch(:test)
85
+ puts("Tests: #{ci_status_label(test[:success])}")
86
+ end
87
+
88
+ def print_ci_coverage_summary
89
+ coverage = CI_STATE.fetch(:coverage)
90
+ line = coverage[:line]
91
+ min = coverage[:minimum]
92
+
93
+ unless line
94
+ puts("Coverage: (no data)")
95
+ return
96
+ end
97
+
98
+ min_label = min ? format(" (min %<min>d%%)", min: min) : ""
99
+ puts(format("Coverage: %<line>.1f%%%<min_label>s", line: line, min_label: min_label))
100
+ end
101
+
102
+ def print_ci_summary
103
+ announce("Summary")
104
+ print_ci_rubocop_summary
105
+ print_ci_test_summary
106
+ print_ci_coverage_summary
107
+ end
108
+
109
+ def init_rubocop_state
110
+ record_ci(:rubocop, :ran, true)
111
+ record_ci(:rubocop, :reran, false)
112
+ end
113
+
114
+ def run_rubocop_pass(command, label)
115
+ announce(label)
116
+ run_and_echo(command)
117
+ end
118
+
119
+ def record_rubocop_corrections(output)
120
+ corrected = rubocop_corrected_count(output)
121
+ record_ci(:rubocop, :corrected, corrected)
122
+ corrected
123
+ end
124
+
125
+ def rerun_rubocop_if_corrected(command, corrected)
126
+ return nil unless corrected.positive?
127
+
128
+ announce("Re-running RuboCop (-A) after corrections")
129
+ record_ci(:rubocop, :reran, true)
130
+ _output, status = run_and_echo(command)
131
+ status
132
+ end
133
+
134
+ def finalize_rubocop(status)
135
+ record_ci(:rubocop, :success, status.success?)
136
+ abort("RuboCop failed") unless status.success?
137
+ end
138
+
139
+ def run_ci_rubocop
140
+ command = "bundle exec rubocop -A ."
141
+ init_rubocop_state
142
+ output, status = run_rubocop_pass(command, "Running RuboCop (-A)")
143
+ corrected = record_rubocop_corrections(output)
144
+ status = rerun_rubocop_if_corrected(command, corrected) || status
145
+ finalize_rubocop(status)
146
+ rescue Errno::ENOENT => e
147
+ abort(e.message)
148
+ end
149
+
150
+ def run_ci_tests
151
+ announce("Running tests (Minitest + SimpleCov)")
152
+
153
+ record_ci(:test, :ran, true)
154
+ record_ci(:coverage, :minimum, ENV.fetch("MINIMUM_COVERAGE", "100").to_i)
155
+
156
+ Rake::Task["test"].invoke
157
+ record_ci(:test, :success, true)
158
+ rescue SystemExit, StandardError
159
+ record_ci(:test, :success, false)
160
+ raise
161
+ ensure
162
+ record_ci(:coverage, :line, read_line_coverage)
163
+ end
164
+
165
+ namespace :ci do
166
+ desc "Run RuboCop with autocorrect (-A) (twice if corrections were made)"
167
+ task :rubocop do
168
+ run_ci_rubocop
169
+ end
170
+
171
+ desc "Run tests (with coverage)"
172
+ task :test do
173
+ run_ci_tests
174
+ end
175
+
176
+ desc "Print a summary of the last CI run"
177
+ task :summary do
178
+ record_ci(:coverage, :minimum, ENV.fetch("MINIMUM_COVERAGE", "100").to_i)
179
+ record_ci(:coverage, :line, read_line_coverage)
180
+ print_ci_summary
181
+ end
182
+ end
183
+
184
+ desc "Run RuboCop and tests (with coverage)"
185
+ task :ci do
186
+ Rake::Task["ci:rubocop"].invoke
187
+ Rake::Task["ci:test"].invoke
188
+ ensure
189
+ print_ci_summary
190
+ end
191
+
192
+ if YARD_AVAILABLE
193
+ YARD::Rake::YardocTask.new(:yard)
194
+ desc "Alias for `rake yard`"
195
+ task doc: :yard
196
+ end
data/bin/console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "wheneverd"
6
+
7
+ require "irb"
8
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ bundle install
5
+
data/exe/wheneverd ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require "wheneverd/cli"
8
+
9
+ exit(Wheneverd::CLI.run || 0)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd activate` (enable + start timers via `systemctl --user`).
5
+ class CLI::Activate < CLI
6
+ def execute
7
+ timer_units = timer_unit_basenames
8
+ return 0 if timer_units.empty?
9
+
10
+ Wheneverd::Systemd::Systemctl.run("daemon-reload")
11
+ Wheneverd::Systemd::Systemctl.run("enable", "--now", *timer_units)
12
+
13
+ timer_units.each { |unit| puts unit }
14
+ 0
15
+ rescue StandardError => e
16
+ handle_error(e)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd current` (print installed unit files from disk).
5
+ class CLI::Current < CLI
6
+ def execute
7
+ paths = Wheneverd::Systemd::UnitLister.list(identifier: identifier_value, unit_dir: unit_dir)
8
+ print_unit_files(paths)
9
+ 0
10
+ rescue StandardError => e
11
+ handle_error(e)
12
+ end
13
+
14
+ def print_unit_files(paths)
15
+ paths.each_with_index do |path, idx|
16
+ puts "# #{path}"
17
+ puts File.read(path)
18
+ puts "" if idx < paths.length - 1
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd deactivate` (stop + disable timers via `systemctl --user`).
5
+ class CLI::Deactivate < CLI
6
+ def execute
7
+ timer_units = timer_unit_basenames
8
+ return 0 if timer_units.empty?
9
+
10
+ Wheneverd::Systemd::Systemctl.run("stop", *timer_units)
11
+ Wheneverd::Systemd::Systemctl.run("disable", *timer_units)
12
+
13
+ timer_units.each { |unit| puts unit }
14
+ 0
15
+ rescue StandardError => e
16
+ handle_error(e)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd delete` (delete previously generated units for the identifier).
5
+ class CLI::Delete < CLI
6
+ option "--dry-run", :flag, "Print paths only; do not delete"
7
+
8
+ def execute
9
+ paths = Wheneverd::Systemd::UnitDeleter.delete(
10
+ identifier: identifier_value,
11
+ unit_dir: unit_dir,
12
+ dry_run: dry_run?
13
+ )
14
+ paths.each { |p| puts p }
15
+ 0
16
+ rescue StandardError => e
17
+ handle_error(e)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd help` and the default command.
5
+ #
6
+ # The `--version` flag is supported here to match the common "help-or-version" UX.
7
+ class CLI::Help < CLI
8
+ def execute
9
+ if version?
10
+ puts Wheneverd::VERSION
11
+ return 0
12
+ end
13
+
14
+ warn Wheneverd::CLI.help(invocation_path.split.first)
15
+ 1
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Wheneverd
6
+ # Implements `wheneverd init` to create a schedule template file.
7
+ class CLI::Init < CLI
8
+ option "--force", :flag, "Overwrite existing file"
9
+
10
+ TEMPLATE = <<~RUBY
11
+ # frozen_string_literal: true
12
+
13
+ # This file is evaluated as Ruby.
14
+ #
15
+ # Supported `every` period forms:
16
+ # - interval strings: "5m", "1h", "2d"
17
+ # - duration objects: 1.day, 2.hours
18
+ # - symbol shortcuts: :hour, :day, :month, :year
19
+ # - day selectors: :monday..:sunday, :weekday, :weekend (multiple day symbols supported)
20
+ # - cron strings (5 fields): "0 0 27-31 * *"
21
+
22
+ every "5m" do
23
+ command "echo hello"
24
+ end
25
+
26
+ every 1.day, at: "4:30 am" do
27
+ command "echo four_thirty"
28
+ end
29
+
30
+ every 1.day, at: ["4:30 am", "6:00 pm"] do
31
+ command "echo twice_daily"
32
+ end
33
+
34
+ every :hour do
35
+ command "echo hourly"
36
+ end
37
+
38
+ every :sunday, at: "12pm" do
39
+ command "echo weekly"
40
+ end
41
+
42
+ every :tuesday, :wednesday, at: "12pm" do
43
+ command "echo midweek"
44
+ end
45
+
46
+ every "0 0 27-31 * *" do
47
+ command "echo raw_cron"
48
+ end
49
+ RUBY
50
+
51
+ def execute
52
+ path = File.expand_path(schedule)
53
+ return 1 if refuse_overwrite_without_force?(path)
54
+
55
+ existed = write_template(path)
56
+ puts "#{existed ? 'Overwrote' : 'Wrote'} schedule template to #{path}"
57
+ 0
58
+ rescue StandardError => e
59
+ handle_error(e)
60
+ end
61
+
62
+ private
63
+
64
+ def refuse_overwrite_without_force?(path)
65
+ return false unless File.exist?(path) && !force?
66
+
67
+ warn "#{path}: already exists (use --force to overwrite)"
68
+ true
69
+ end
70
+
71
+ def write_template(path)
72
+ existed = File.exist?(path)
73
+ FileUtils.mkdir_p(File.dirname(path))
74
+ File.write(path, TEMPLATE)
75
+ existed
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd reload` (write units, reload daemon, restart timers).
5
+ class CLI::Reload < CLI
6
+ option "--[no-]prune", :flag,
7
+ "Prune previously generated units for the identifier (default: enabled)",
8
+ default: true
9
+
10
+ def execute
11
+ paths, timer_units = write_units_and_timer_basenames
12
+ return 0 if timer_units.empty?
13
+
14
+ reload_systemd(timer_units)
15
+
16
+ paths.each { |path| puts path }
17
+ 0
18
+ rescue StandardError => e
19
+ handle_error(e)
20
+ end
21
+
22
+ private
23
+
24
+ def write_units_and_timer_basenames
25
+ units = render_units
26
+ paths = Wheneverd::Systemd::UnitWriter.write(
27
+ units,
28
+ unit_dir: unit_dir,
29
+ prune: prune?,
30
+ identifier: identifier_value
31
+ )
32
+ [paths, timer_unit_basenames(units)]
33
+ end
34
+
35
+ def reload_systemd(timer_units)
36
+ Wheneverd::Systemd::Systemctl.run("daemon-reload")
37
+ Wheneverd::Systemd::Systemctl.run("restart", *timer_units)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd show` (render units to stdout).
5
+ class CLI::Show < CLI
6
+ def execute
7
+ schedule_obj = load_schedule
8
+ units = Wheneverd::Systemd::Renderer.render(schedule_obj, identifier: identifier_value)
9
+ print_units(units)
10
+ 0
11
+ rescue StandardError => e
12
+ handle_error(e)
13
+ end
14
+
15
+ def print_units(units)
16
+ units.each_with_index do |unit, idx|
17
+ puts "# #{unit.path_basename}"
18
+ puts unit.contents
19
+ puts "" if idx < units.length - 1
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # Implements `wheneverd write` (write rendered units to disk).
5
+ class CLI::Write < CLI
6
+ option "--dry-run", :flag, "Print paths only; do not write"
7
+ option "--[no-]prune", :flag,
8
+ "Prune previously generated units for the identifier (default: enabled)",
9
+ default: true
10
+
11
+ def execute
12
+ write_paths.each { |path| puts path }
13
+ 0
14
+ rescue StandardError => e
15
+ handle_error(e)
16
+ end
17
+
18
+ private
19
+
20
+ def write_paths
21
+ schedule_obj = load_schedule
22
+ units = Wheneverd::Systemd::Renderer.render(schedule_obj, identifier: identifier_value)
23
+ Wheneverd::Systemd::UnitWriter.write(
24
+ units,
25
+ unit_dir: unit_dir,
26
+ dry_run: dry_run?,
27
+ prune: prune?,
28
+ identifier: identifier_value
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clamp"
4
+ require_relative "../wheneverd"
5
+
6
+ module Wheneverd
7
+ # Command-line interface for `wheneverd`.
8
+ #
9
+ # This class defines global options and shared helpers used by each subcommand.
10
+ class CLI < Clamp::Command
11
+ option ["-v", "--version"], :flag, "Print version"
12
+ option "--verbose", :flag, "Verbose output"
13
+ option "--schedule", "PATH", "Schedule file path", default: "config/schedule.rb"
14
+ option "--identifier", "NAME", "Unit identifier (defaults to current directory name)"
15
+ option "--unit-dir", "PATH", "systemd unit directory",
16
+ default: Wheneverd::Systemd::UnitWriter::DEFAULT_UNIT_DIR
17
+
18
+ # @return [String] the identifier used for unit file names
19
+ def identifier_value
20
+ identifier || File.basename(Dir.pwd)
21
+ end
22
+
23
+ # Load the configured schedule file.
24
+ #
25
+ # @return [Wheneverd::Schedule]
26
+ def load_schedule
27
+ path = File.expand_path(schedule)
28
+ unless File.file?(path)
29
+ raise Wheneverd::DSL::LoadError.new("Schedule file not found: #{path}", path: path)
30
+ end
31
+
32
+ Wheneverd::DSL::Loader.load_file(path)
33
+ end
34
+
35
+ # Print an error message and return a non-zero exit status.
36
+ #
37
+ # @param error [Exception]
38
+ # @return [Integer]
39
+ def handle_error(error)
40
+ warn error.message
41
+ warn error.full_message if verbose?
42
+ 1
43
+ end
44
+
45
+ # Render schedule units for this invocation.
46
+ #
47
+ # @return [Array<Wheneverd::Systemd::Unit>]
48
+ def render_units
49
+ schedule_obj = load_schedule
50
+ Wheneverd::Systemd::Renderer.render(schedule_obj, identifier: identifier_value)
51
+ end
52
+
53
+ # @param units [Array<Wheneverd::Systemd::Unit>]
54
+ # @return [Array<String>] timer unit basenames
55
+ def timer_unit_basenames(units = render_units)
56
+ units.select { |unit| unit.kind == :timer }.map(&:path_basename).uniq
57
+ end
58
+
59
+ private :render_units, :timer_unit_basenames
60
+ end
61
+ end
62
+
63
+ require_relative "cli/help"
64
+ require_relative "cli/init"
65
+ require_relative "cli/show"
66
+ require_relative "cli/write"
67
+ require_relative "cli/delete"
68
+ require_relative "cli/activate"
69
+ require_relative "cli/deactivate"
70
+ require_relative "cli/reload"
71
+ require_relative "cli/current"
72
+
73
+ module Wheneverd
74
+ class CLI
75
+ self.default_subcommand = "help"
76
+
77
+ subcommand "help", "Show help", Wheneverd::CLI::Help
78
+ subcommand "init", "Create a schedule template", Wheneverd::CLI::Init
79
+ subcommand "show", "Render units to stdout", Wheneverd::CLI::Show
80
+ subcommand "write", "Write units to disk", Wheneverd::CLI::Write
81
+ subcommand "delete", "Delete units from disk", Wheneverd::CLI::Delete
82
+ subcommand "activate", "Enable and start timers via systemctl --user", Wheneverd::CLI::Activate
83
+ subcommand "deactivate", "Stop and disable timers via systemctl --user", Wheneverd::CLI::Deactivate
84
+ subcommand "reload", "Write units, reload daemon, restart timers", Wheneverd::CLI::Reload
85
+ subcommand "current", "Show installed units from disk", Wheneverd::CLI::Current
86
+ end
87
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module CoreExt
5
+ # `Numeric` helpers for creating {Wheneverd::Duration} values.
6
+ #
7
+ # @example
8
+ # every 5.minutes do
9
+ # command "echo hello"
10
+ # end
11
+ module NumericDuration
12
+ def second
13
+ Wheneverd::Duration.new(to_duration_seconds(1))
14
+ end
15
+ alias seconds second
16
+
17
+ def minute
18
+ Wheneverd::Duration.new(to_duration_seconds(60))
19
+ end
20
+ alias minutes minute
21
+
22
+ def hour
23
+ Wheneverd::Duration.new(to_duration_seconds(60 * 60))
24
+ end
25
+ alias hours hour
26
+
27
+ def day
28
+ Wheneverd::Duration.new(to_duration_seconds(60 * 60 * 24))
29
+ end
30
+ alias days day
31
+
32
+ def week
33
+ Wheneverd::Duration.new(to_duration_seconds(60 * 60 * 24 * 7))
34
+ end
35
+ alias weeks week
36
+
37
+ private
38
+
39
+ def to_duration_seconds(multiplier)
40
+ unless is_a?(Integer)
41
+ raise ArgumentError, "Duration helpers require an Integer receiver (got #{self.class})"
42
+ end
43
+
44
+ self * multiplier
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # Extend Ruby's `Numeric` with duration helpers.
51
+ #
52
+ # @!parse
53
+ # class ::Numeric
54
+ # include Wheneverd::CoreExt::NumericDuration
55
+ # end
56
+ Numeric.include(Wheneverd::CoreExt::NumericDuration)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ # Validates and normalizes the `at:` option from the schedule DSL.
6
+ #
7
+ # `at:` can be a single string (e.g. `"4:30 am"`) or an array of strings (multiple run times).
8
+ module AtNormalizer
9
+ # @param at [String, Array<String>, nil]
10
+ # @param path [String] schedule path for error reporting
11
+ # @return [Array<String>] normalized time strings (not parsed)
12
+ def self.normalize(at, path:)
13
+ return [] if at.nil?
14
+
15
+ return [normalize_string(at, path: path)] if at.is_a?(String)
16
+
17
+ return normalize_array(at, path: path) if at.is_a?(Array)
18
+
19
+ raise InvalidAtError.new("at: must be a String or an Array of Strings", path: path)
20
+ end
21
+
22
+ def self.normalize_string(value, path:)
23
+ at_str = value.strip
24
+ raise InvalidAtError.new("at: must not be empty", path: path) if at_str.empty?
25
+
26
+ at_str
27
+ end
28
+ private_class_method :normalize_string
29
+
30
+ def self.normalize_array(values, path:)
31
+ times = values.map do |v|
32
+ unless v.is_a?(String)
33
+ raise InvalidAtError.new("at: must be a String or an Array of Strings", path: path)
34
+ end
35
+
36
+ v.strip
37
+ end
38
+
39
+ if times.empty? || times.any?(&:empty?)
40
+ raise InvalidAtError.new("at: must not be empty", path: path)
41
+ end
42
+
43
+ times
44
+ end
45
+ private_class_method :normalize_array
46
+ end
47
+ end
48
+ end