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.
- checksums.yaml +7 -0
- data/.gitignore +26 -0
- data/.rubocop.yml +41 -0
- data/.yardopts +8 -0
- data/AGENTS.md +42 -0
- data/CHANGELOG.md +28 -0
- data/FEATURE_SUMMARY.md +38 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +129 -0
- data/LICENSE +21 -0
- data/README.md +204 -0
- data/Rakefile +196 -0
- data/bin/console +8 -0
- data/bin/setup +5 -0
- data/exe/wheneverd +9 -0
- data/lib/wheneverd/cli/activate.rb +19 -0
- data/lib/wheneverd/cli/current.rb +22 -0
- data/lib/wheneverd/cli/deactivate.rb +19 -0
- data/lib/wheneverd/cli/delete.rb +20 -0
- data/lib/wheneverd/cli/help.rb +18 -0
- data/lib/wheneverd/cli/init.rb +78 -0
- data/lib/wheneverd/cli/reload.rb +40 -0
- data/lib/wheneverd/cli/show.rb +23 -0
- data/lib/wheneverd/cli/write.rb +32 -0
- data/lib/wheneverd/cli.rb +87 -0
- data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
- data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
- data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
- data/lib/wheneverd/dsl/context.rb +72 -0
- data/lib/wheneverd/dsl/errors.rb +29 -0
- data/lib/wheneverd/dsl/loader.rb +49 -0
- data/lib/wheneverd/dsl/period_parser.rb +135 -0
- data/lib/wheneverd/duration.rb +27 -0
- data/lib/wheneverd/entry.rb +31 -0
- data/lib/wheneverd/errors.rb +9 -0
- data/lib/wheneverd/interval.rb +37 -0
- data/lib/wheneverd/job/command.rb +29 -0
- data/lib/wheneverd/schedule.rb +25 -0
- data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
- data/lib/wheneverd/systemd/cron_parser.rb +352 -0
- data/lib/wheneverd/systemd/errors.rb +23 -0
- data/lib/wheneverd/systemd/renderer.rb +153 -0
- data/lib/wheneverd/systemd/systemctl.rb +38 -0
- data/lib/wheneverd/systemd/time_parser.rb +75 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
- data/lib/wheneverd/systemd/unit_lister.rb +59 -0
- data/lib/wheneverd/systemd/unit_namer.rb +69 -0
- data/lib/wheneverd/systemd/unit_writer.rb +132 -0
- data/lib/wheneverd/trigger/boot.rb +26 -0
- data/lib/wheneverd/trigger/calendar.rb +26 -0
- data/lib/wheneverd/trigger/interval.rb +30 -0
- data/lib/wheneverd/version.rb +6 -0
- data/lib/wheneverd.rb +41 -0
- data/test/cli_activate_test.rb +110 -0
- data/test/cli_current_test.rb +94 -0
- data/test/cli_deactivate_test.rb +111 -0
- data/test/cli_end_to_end_test.rb +98 -0
- data/test/cli_reload_test.rb +132 -0
- data/test/cli_systemctl_integration_test.rb +76 -0
- data/test/cli_systemd_analyze_test.rb +64 -0
- data/test/cli_test.rb +332 -0
- data/test/domain_model_test.rb +108 -0
- data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
- data/test/dsl_loader_test.rb +384 -0
- data/test/support/cli_subprocess_test_helpers.rb +38 -0
- data/test/support/cli_test_helpers.rb +114 -0
- data/test/systemd_calendar_spec_test.rb +45 -0
- data/test/systemd_cron_parser_test.rb +114 -0
- data/test/systemd_renderer_errors_test.rb +85 -0
- data/test/systemd_renderer_test.rb +161 -0
- data/test/systemd_systemctl_test.rb +46 -0
- data/test/systemd_time_parser_test.rb +25 -0
- data/test/systemd_unit_deleter_test.rb +83 -0
- data/test/systemd_unit_writer_prune_test.rb +85 -0
- data/test/systemd_unit_writer_test.rb +71 -0
- data/test/test_helper.rb +34 -0
- data/test/wheneverd_test.rb +9 -0
- data/wheneverd.gemspec +35 -0
- 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
data/bin/setup
ADDED
data/exe/wheneverd
ADDED
|
@@ -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
|