do_run 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.
data/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # do
2
+
3
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-blue)
4
+ ![Gem](https://img.shields.io/gem/v/do)
5
+ ![Downloads](https://img.shields.io/gem/dt/do)
6
+ ![CI](https://img.shields.io/github/actions/workflow/status/sahilium/do/ci.yml?label=CI)
7
+ ![License](https://img.shields.io/github/license/sahilium/do)
8
+
9
+ `do` is a **declarative execution layer for Linux**. It translates a small
10
+ TOML configuration into **systemd user services and timers**, giving you a
11
+ friendly control plane without reinventing scheduling.
12
+
13
+ `do` is *not* a scheduler, service manager, shell, or configuration-management
14
+ system. It is the declarative control plane on top of systemd.
15
+
16
+ ## Why it exists
17
+
18
+ Linux already has excellent primitives for running and scheduling work:
19
+ systemd services and timers. What's missing is a small, human-friendly,
20
+ declarative way to describe a task and have that translated into systemd units
21
+ automatically. `do` fills that gap. Say what to run, when to run it, and under
22
+ what environment/who directory; `do` turns that into working units.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ gem install do
28
+ ```
29
+
30
+ The executable is `do`.
31
+
32
+ For development:
33
+
34
+ ```bash
35
+ bundle exec ruby bin/do
36
+ ```
37
+
38
+ Requires Linux with a running systemd **user** session.
39
+
40
+ ## Configuration format
41
+
42
+ Configuration lives in a single TOML file:
43
+
44
+ ```text
45
+ ~/.config/do/config.toml
46
+ ```
47
+
48
+ The TOML file is the **source of truth**. Generated systemd units are derived
49
+ artifacts and are never the primary configuration mechanism.
50
+
51
+ ### Example
52
+
53
+ ```toml
54
+ [tasks.backup]
55
+ command = "restic backup ~/Documents"
56
+ schedule = "daily"
57
+ time = "02:00"
58
+ enabled = true
59
+
60
+ [tasks.update]
61
+ command = "do update"
62
+ schedule = "weekly"
63
+ day = "sunday"
64
+ time = "10:00"
65
+ enabled = true
66
+
67
+ [tasks.report]
68
+ command = "/home/eugene/bin/generate-report"
69
+ working_directory = "/home/eugene/projects/report"
70
+ schedule = "monthly"
71
+ day = "1"
72
+ time = "18:00"
73
+
74
+ [tasks.report.environment]
75
+ REPORT_FORMAT = "markdown"
76
+ ```
77
+
78
+ ### Task schema
79
+
80
+ | Field | Required | Notes |
81
+ |---------------------|----------|--------------------------------------------------------------|
82
+ | `command` | yes | Command to execute (an argument list, executed without a shell) |
83
+ | `schedule` | no | `once`, `hourly`, `daily`, `weekly`, `monthly`. Omit for manual-only (default). |
84
+ | `time` | no | Clock time in `HH:MM`. Defaults to `00:00`. |
85
+ | `day` | no | Weekday name (`sunday`) for `weekly`; day-of-month (`1`) for `monthly`. |
86
+ | `working_directory` | no | Directory the command runs in. |
87
+ | `environment` | no | Table mapping `KEY = "value"` to export to the command. |
88
+ | `enabled` | no | `true` for scheduled tasks that should run, `false` otherwise. |
89
+
90
+ **`enabled` default behavior:** defaults to `true` when the task has a
91
+ schedule, and `false` for manual-only (unscheduled) tasks. An explicit value
92
+ always wins.
93
+
94
+ **Commands are not shell strings.** A `command` is split into an argument list
95
+ lexically. No `~` expansion, no globbing, no environment interpolation, no
96
+ shell operators. If you genuinely need shell behavior, ask for it explicitly:
97
+
98
+ ```toml
99
+ command = "bash -lc 'commit && push'"
100
+ ```
101
+
102
+ ## Running a task
103
+
104
+ ```bash
105
+ do run backup # run immediately; returns the command's exit status
106
+ do backup # shorthand for `do run backup`
107
+ ```
108
+
109
+ Manual execution never alters a scheduled timer.
110
+
111
+ ## Scheduling a task
112
+
113
+ Schedules are declared in the TOML file; `do reload` turns them into systemd
114
+ user timers.
115
+
116
+ ```bash
117
+ do reload # generate/update units, remove stale ones, reload systemd
118
+ do schedule backup # ensure a scheduled task's timer exists and is enabled
119
+ do unschedule backup # disable and remove the timer (keeps the TOML entry)
120
+ ```
121
+
122
+ Schedule types and their systemd `OnCalendar=` mappings:
123
+
124
+ | Schedule | Meaning | Example |
125
+ |----------|-------------------------------------------|----------------------------------|
126
+ | `once` | Run once when the timer is activated | `OnActiveSec=0` |
127
+ | `hourly` | Every hour at the configured minute | `*-*-* *:30:00` |
128
+ | `daily` | Every day at `time` | `*-*-* 02:00:00` |
129
+ | `weekly` | Every week on `day` at `time` | `Sun *-*-* 10:00:00` |
130
+ | `monthly`| Every month on day-of-month `day` at time | `*-*-1 09:00:00` |
131
+
132
+ `do` never implements its own timer loop; it always maps onto systemd's native
133
+ calendar syntax.
134
+
135
+ ## Enabling / disabling tasks
136
+
137
+ ```bash
138
+ do enable backup # activates the timer (writes enabled = true, reloads)
139
+ do disable backup # deactivates the timer (writes enabled = false, reloads)
140
+ ```
141
+
142
+ Disabling never deletes the TOML configuration.
143
+
144
+ ## Inspecting status
145
+
146
+ ```bash
147
+ do list # table of tasks, status, schedule, next run
148
+ do status backup # enabled/disabled, running, schedule, next/last run
149
+ do validate # validate the configuration without changing anything
150
+ do doctor # check the environment and configuration
151
+ ```
152
+
153
+ ## Viewing logs
154
+
155
+ ```bash
156
+ do logs backup # journalctl --user for the task's service
157
+ do logs backup --follow # follow new lines
158
+ do logs backup --tail 100 # last 100 lines
159
+ ```
160
+
161
+ ## Removing tasks
162
+
163
+ ```bash
164
+ do remove backup # asks for confirmation
165
+ do remove backup --yes # skip confirmation; removes TOML entry + units
166
+ do remove backup --yes --keep-config # keep the TOML entry, remove only units
167
+ ```
168
+
169
+ Removal requires confirmation unless `--yes` is provided.
170
+
171
+ ## Other commands
172
+
173
+ ```bash
174
+ do validate # validate the configuration
175
+ do reload # sync units with the configuration
176
+ do doctor # environment/configuration diagnostics
177
+ do version # print the version
178
+ do help # show all commands
179
+ do config path # print the config file path
180
+ do config show # print the parsed/effective configuration
181
+ ```
182
+
183
+ ## Architecture
184
+
185
+ ```
186
+ do/
187
+ ├── bin/do # executable (thin ARGV dispatch + CLI)
188
+ ├── lib/do.rb # requires the library
189
+ ├── lib/do/
190
+ │ ├── cli.rb # Thor CLI + subcommands
191
+ │ ├── config.rb # TOML parsing, default path, config editing
192
+ │ ├── task.rb # task model + `enabled` defaults
193
+ │ ├── scheduler.rb # declarative schedule -> OnCalendar expressions
194
+ │ ├── systemd.rb # unit generation + systemctl/journalctl wrapper
195
+ │ ├── executor.rb # safe no-shell command execution
196
+ │ ├── validator.rb # stateless configuration validation
197
+ │ └── manager.rb # applies config to the systemd user manager
198
+ ├── spec/ # RSpec suite (systemd mocked)
199
+ ├── do.gemspec
200
+ ├── Gemfile
201
+ ├── README.md
202
+ └── CHANGELOG.md
203
+ ```
204
+
205
+ ## systemd integration
206
+
207
+ Each scheduled task generates two user units under
208
+ `~/.config/systemd/user/`:
209
+
210
+ ```text
211
+ do-backup.service
212
+ do-backup.timer
213
+ ```
214
+
215
+ `do` manages units through the current user's systemd user manager
216
+ (`systemctl --user ...`), never system-wide units, never requiring root. All
217
+ generated files carry a `Managed by do` marker. `do reload`:
218
+
219
+ 1. Parses and validates the TOML.
220
+ 2. Generates/updates the required units.
221
+ 3. Removes obsolete `do`-managed units.
222
+ 4. Runs `systemctl --user daemon-reload`.
223
+ 5. Enables/disables timers according to configuration.
224
+
225
+ Units **not** carrying the `do` marker are never touched.
226
+
227
+ ## v1.0 limitations
228
+
229
+ - Linux with a systemd user session is required.
230
+ - Command execution is shell-free by default; shell behavior must be requested
231
+ explicitly.
232
+ - No retries, DAGs, event triggers, secrets, remote/container/distributed
233
+ execution, or a persistent daemon.
234
+ - `once` runs once on timer activation (no calendar persistence).
235
+ - No automatic dependency installation.
236
+
237
+ ## Development
238
+
239
+ ```bash
240
+ bundle install
241
+ bundle exec rspec
242
+ ```
243
+
244
+ The normal test suite mocks systemd and does not require a real user session.
data/bin/do ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/do'
5
+
6
+ # `do <task>` is shorthand for `do run <task>` when the first argument is not
7
+ # a built-in command.
8
+ ARGV.unshift('run') if ARGV[0] && !ARGV[0].start_with?('-') && !Do::CLI::BUILTINS.include?(ARGV[0])
9
+
10
+ Do::CLI.start
data/lib/do/cli.rb ADDED
@@ -0,0 +1,448 @@
1
+ require 'thor'
2
+ require 'shellwords'
3
+ require 'time'
4
+ require_relative 'version'
5
+ require_relative 'error'
6
+ require_relative 'config'
7
+ require_relative 'task'
8
+ require_relative 'scheduler'
9
+ require_relative 'systemd'
10
+ require_relative 'validator'
11
+ require_relative 'executor'
12
+ require_relative 'manager'
13
+
14
+ module Do
15
+ # `do config path` / `do config show`.
16
+ class ConfigCommand < Thor
17
+ namespace 'config'
18
+
19
+ desc 'path', 'Print the configuration file path.'
20
+ def path
21
+ say ENV['DO_CONFIG'] || Do::Config.default_path
22
+ end
23
+
24
+ desc 'show', 'Print the parsed/effective configuration.'
25
+ def show
26
+ cfg_path = ENV['DO_CONFIG'] || Do::Config.default_path
27
+ raise Do::Error, "Configuration file does not exist: #{cfg_path}" unless File.exist?(cfg_path)
28
+
29
+ config = Do::Config.load(cfg_path)
30
+ errors = Do::Validator.validate(config)
31
+ unless errors.empty?
32
+ errors.each { |e| say "Error: #{e}", :red }
33
+ exit(1)
34
+ end
35
+ say "path: #{cfg_path}"
36
+ say 'tasks:'
37
+ config.tasks.each do |task|
38
+ say " #{task.name}:"
39
+ say " command: #{task.command}"
40
+ say " schedule: #{task.schedule || 'manual'}"
41
+ say " time: #{task.time || '-'}"
42
+ say " day: #{task.day || '-'}"
43
+ say " working_directory: #{task.working_directory || '-'}"
44
+ say " enabled: #{task.enabled?}"
45
+ say " environment: #{task.environment.inspect}" unless (task.environment || {}).empty?
46
+ end
47
+ end
48
+ end
49
+
50
+ # Thor-based CLI. Every command follows the Unix-friendly style:
51
+ #
52
+ # do run backup
53
+ # do list
54
+ # do status backup
55
+ #
56
+ # Configuration is loaded lazily per command so that short commands like
57
+ # `do version` don't need a valid config file.
58
+ class CLI < Thor
59
+ def self.exit_on_failure?
60
+ true
61
+ end
62
+
63
+ # `run` is a Thor reserved word; we want it as a real command.
64
+ def self.is_thor_reserved_word?(_word, _type)
65
+ false
66
+ end
67
+
68
+ # Commands that are taken literally rather than treated as task names by
69
+ # the shorthand `do <task>` form.
70
+ BUILTINS = %w[
71
+ run list status logs enable disable remove validate reload
72
+ schedule unschedule doctor version help config
73
+ ].freeze
74
+
75
+ class << self
76
+ def dispatch(meth, given_args, given_opts, config)
77
+ super
78
+ rescue Do::Error => e
79
+ warn "Error: #{e.message}"
80
+ exit 1
81
+ end
82
+ end
83
+
84
+ desc 'run <task>', 'Execute a task immediately.'
85
+ def run(task = nil)
86
+ task = require_task!(task)
87
+ say "> do run #{task.name}: #{task.command}"
88
+ code = Executor.new.run(task)
89
+ say " finished (exit #{code})"
90
+ exit(code) if code != 0
91
+ end
92
+
93
+ desc 'list', 'List configured tasks.'
94
+ def list
95
+ config = load_config!(validate: false)
96
+ say header = 'TASK STATUS SCHEDULE NEXT'
97
+ say '-' * header.length
98
+ config.tasks.each do |task|
99
+ say format_list_row(task, systemd)
100
+ end
101
+ end
102
+
103
+ desc 'status <task>', 'Show detail for a single task.'
104
+ def status(task = nil)
105
+ task = require_task!(task)
106
+ show_task_status(task)
107
+ end
108
+
109
+ desc 'logs <task>', "Show logs for a task's service."
110
+ option :follow, aliases: '-f', type: :boolean, desc: 'Follow new log lines.'
111
+ option :tail, aliases: '-n', type: :numeric, desc: 'Show last N lines.'
112
+ def logs(task = nil)
113
+ task = require_task!(task)
114
+ units = units_for(task)
115
+ raise Do::Error, "task '#{task.name}' has no generated service unit." if units[:service].nil?
116
+
117
+ say "Journal for #{units[:service]} (#{task.name}):" if $stdout.tty?
118
+ ok = systemd.logs(units[:service], follow: options[:follow], tail: options[:tail])
119
+ exit(1) unless ok
120
+ end
121
+
122
+ desc 'enable <task>', 'Mark a scheduled task enabled and activate its timer.'
123
+ def enable(task = nil)
124
+ change_enabled(task, true)
125
+ end
126
+
127
+ desc 'disable <task>', 'Mark a scheduled task disabled without removing it.'
128
+ def disable(task = nil)
129
+ change_enabled(task, false)
130
+ end
131
+
132
+ desc 'remove <task>', "Remove a task's generated units (confirmation required)."
133
+ option :yes, type: :boolean, aliases: '-y', desc: 'Skip confirmation.'
134
+ option :'keep-config', type: :boolean, desc: 'Only remove generated units, keep the TOML entry.'
135
+ def remove(task = nil)
136
+ config = load_config!(validate: false)
137
+ task = require_task!(task, config)
138
+ manager = Manager.new(systemd: systemd, config: config)
139
+
140
+ confirm!("Remove generated units for task '#{task.name}'?") unless options[:yes]
141
+ manager.remove_units_for(task)
142
+ if options[:'keep-config']
143
+ say "Removed generated units for '#{task.name}' (kept configuration)."
144
+ else
145
+ config.remove_task(task.name)
146
+ say "Removed task '#{task.name}' from configuration and removed its units."
147
+ end
148
+ end
149
+
150
+ desc 'validate', 'Validate the configuration without modifying the system.'
151
+ def validate
152
+ path = config_path
153
+ raise Do::Error, "Configuration file does not exist: #{path}" unless File.exist?(path)
154
+
155
+ config = Config.load(path)
156
+ errors = Validator.validate(config)
157
+ if errors.empty?
158
+ say "OK: configuration is valid (#{config.tasks.size} task(s))."
159
+ else
160
+ errors.each { |e| say "Error: #{e}", :red }
161
+ exit(1)
162
+ end
163
+ end
164
+
165
+ desc 'reload', 'Generate/update units, remove stale ones, and reload systemd.'
166
+ def reload
167
+ config = load_config!(validate: true)
168
+ Manager.new(systemd: systemd, config: config).reload
169
+ say "Reloaded units for #{config.tasks.size} task(s)."
170
+ end
171
+
172
+ desc 'schedule <task>', 'Generate the timer for a scheduled task and enable it.'
173
+ def schedule(task = nil)
174
+ config = load_config!(validate: true)
175
+ task = require_task!(task, config)
176
+ unless task.scheduled?
177
+ raise Do::Error,
178
+ "task '#{task.name}' has no schedule in the configuration."
179
+ end
180
+
181
+ Manager.new(systemd: systemd, config: config).reload
182
+ say "Scheduled task '#{task.name}' (#{task.schedule})."
183
+ end
184
+
185
+ desc 'unschedule <task>', "Disable and remove a task's timer (keeps the TOML entry)."
186
+ def unschedule(task = nil)
187
+ config = load_config!(validate: false)
188
+ task = require_task!(task, config)
189
+ Manager.new(systemd: systemd, config: config).unschedule(task)
190
+ say "Unscheduled task '#{task.name}'."
191
+ end
192
+
193
+ desc 'doctor', 'Report on the environment and configuration.'
194
+ def doctor
195
+ run_doctor
196
+ end
197
+
198
+ desc 'config', 'Work with the configuration file.'
199
+ subcommand 'config', ConfigCommand
200
+
201
+ desc 'version', 'Show the version.'
202
+ def version
203
+ say Do::VERSION
204
+ end
205
+
206
+ private
207
+
208
+ def systemd
209
+ @systemd ||= Systemd.new
210
+ end
211
+
212
+ def config_path
213
+ ENV['DO_CONFIG'] || Config.default_path
214
+ end
215
+
216
+ def load_config!(validate: true)
217
+ path = config_path
218
+ unless File.exist?(path)
219
+ raise Do::Error,
220
+ "Configuration file does not exist: #{path}\nCreate it or run 'do config path'."
221
+ end
222
+
223
+ config = Config.load(path)
224
+ Validator.validate!(config) if validate
225
+ config
226
+ end
227
+
228
+ def require_task!(name, config = nil)
229
+ config ||= load_config!(validate: false)
230
+ t = config.task(name.to_s)
231
+ raise_does_not_exist(name) unless t
232
+ t
233
+ end
234
+
235
+ def raise_does_not_exist(name)
236
+ raise Do::Error,
237
+ "task '#{name}' does not exist.\nRun 'do list' to see configured tasks."
238
+ end
239
+
240
+ def units_for(task)
241
+ { service: task.scheduled? ? task.service_unit : nil,
242
+ timer: task.scheduled? ? task.timer_unit : nil }
243
+ end
244
+
245
+ def format_list_row(task, sd)
246
+ sched = schedule_label(task)
247
+ next_time =
248
+ if task.scheduled? && task.enabled?
249
+ t = sd.next_time(task.timer_unit)
250
+ t ? t.strftime('%a %H:%M') : '-'
251
+ else
252
+ '-'
253
+ end
254
+ format('%-10s %-9s %-18s %s',
255
+ task.name, (task.enabled? ? 'enabled' : 'disabled'), sched, next_time)
256
+ end
257
+
258
+ def schedule_label(task)
259
+ return 'manual' unless task.scheduled?
260
+
261
+ case task.schedule
262
+ when 'once' then 'once'
263
+ when 'hourly' then 'hourly'
264
+ when 'daily' then "daily #{task.time || '00:00'}"
265
+ when 'weekly' then "weekly #{short_day(task.day)} #{task.time || '00:00'}"
266
+ when 'monthly' then "monthly day #{task.day || '1'} #{task.time || '00:00'}"
267
+ else task.schedule.to_s
268
+ end
269
+ end
270
+
271
+ def short_day(day)
272
+ day.to_s[0, 3].capitalize
273
+ end
274
+
275
+ def show_task_status(task)
276
+ sd = systemd
277
+ service = task.scheduled? ? task.service_unit : nil
278
+ timer = task.scheduled? ? task.timer_unit : nil
279
+
280
+ state = active_state_label(sd, timer, service)
281
+ say "task: #{task.name}"
282
+ say "status: #{task.enabled? ? 'enabled' : 'disabled'}"
283
+ say "running: #{running_label(sd, service)}"
284
+ say "schedule: #{schedule_label(task)}"
285
+ if timer
286
+ t = sd.next_time(timer)
287
+ say "next: #{t ? t.strftime('%a %Y-%m-%d %H:%M') : 'unknown'}"
288
+ say "active: #{state || '-'}"
289
+ end
290
+ return unless service
291
+
292
+ last = sd.last_active(service)
293
+ code = sd.last_exit_status(service)
294
+ say "last run: #{last ? last.strftime('%Y-%m-%d %H:%M') : 'never'}"
295
+ say "last exit: #{code.nil? ? '-' : code}"
296
+ end
297
+
298
+ def active_state_label(sd, timer, service)
299
+ if timer && sd.active?(timer)
300
+ 'timer active'
301
+ elsif service && sd.active?(service)
302
+ 'service active'
303
+ else
304
+ 'inactive'
305
+ end
306
+ end
307
+
308
+ def running_label(sd, service)
309
+ return 'no unit' if service.nil?
310
+
311
+ sd.active?(service) ? 'yes' : 'no'
312
+ end
313
+
314
+ def change_enabled(task_or_name, value)
315
+ config = load_config!(validate: false)
316
+ task = require_task!(task_or_name, config)
317
+ unless task.scheduled?
318
+ raise Do::Error,
319
+ "manual-only task '#{task.name}' has no timer to #{value ? 'enable' : 'disable'}."
320
+ end
321
+
322
+ config.set_task_field(task.name, 'enabled', value.to_s)
323
+ refreshed = Config.load(config.path)
324
+ Validator.validate!(refreshed)
325
+ Manager.new(systemd: systemd, config: refreshed).reload
326
+ say "#{value ? 'Enabled' : 'Disabled'} task '#{task.name}'."
327
+ end
328
+
329
+ def confirm!(prompt)
330
+ unless $stdin.tty?
331
+ raise Do::Error, 'confirmation required; pass --yes for non-interactive removal.'
332
+ end
333
+
334
+ print "#{prompt} [y/N] "
335
+ ans = $stdin.gets
336
+ return if ans && ans.strip.downcase == 'y'
337
+
338
+ say 'Aborted.'
339
+ exit(1)
340
+ end
341
+
342
+ def run_doctor
343
+ check('Ruby', true, Ruby::VERSION)
344
+ systemctl_ok = systemctl_present?
345
+ check('systemctl', systemctl_ok, systemctl_ok ? nil : 'not found in PATH')
346
+ check('systemd user manager', systemd.user_available?, nil) if systemctl_ok
347
+ check('configuration file', File.exist?(config_path), config_path)
348
+
349
+ if File.exist?(config_path)
350
+ begin
351
+ config = Config.load(config_path)
352
+ errors = Validator.validate(config)
353
+ if errors.empty?
354
+ check('configuration validity', true, nil)
355
+ check_generated_units(config)
356
+ check_enabled_timers(config)
357
+ check_commands(config)
358
+ check_working_dirs(config)
359
+ else
360
+ check('configuration validity', false, errors.first)
361
+ end
362
+ rescue Do::Error => e
363
+ check('configuration validity', false, e.message)
364
+ end
365
+ end
366
+ check('journal', journalctl_present?, nil)
367
+ end
368
+
369
+ def check(name, ok, detail)
370
+ mark = ok ? "\u2713" : "\u2717"
371
+ say "#{mark} #{name}#{" (#{detail})" if detail}"
372
+ end
373
+
374
+ def systemctl_present?
375
+ systemd = systemctl_bin
376
+ systemd && !systemd.empty?
377
+ end
378
+
379
+ def journalctl_present?
380
+ systemctl_bin # journalctl assumed present alongside systemctl
381
+ end
382
+
383
+ def systemctl_bin
384
+ ENV['SYSTEMCTL'] || (system_detect('systemctl') ? 'systemctl' : nil)
385
+ end
386
+
387
+ def system_detect(bin)
388
+ system("command -v #{bin} >/dev/null 2>&1")
389
+ rescue StandardError
390
+ false
391
+ end
392
+
393
+ def check_generated_units(config)
394
+ dir = Systemd.new.unit_dir
395
+ expected = config.tasks.select(&:scheduled?).flat_map do |t|
396
+ [t.service_unit, t.timer_unit]
397
+ end
398
+ stale = Dir.glob(File.join(dir, 'do-*.{service,timer}')).select do |f|
399
+ !expected.include?(File.basename(f)) && managed_file?(f)
400
+ end
401
+ check('generated units (no stale)', stale.empty?,
402
+ stale.empty? ? nil : "stale: #{stale.join(', ')}")
403
+ end
404
+
405
+ def managed_file?(path)
406
+ File.read(path).include?('Managed by do')
407
+ rescue StandardError
408
+ false
409
+ end
410
+
411
+ def check_enabled_timers(config)
412
+ sd = systemd
413
+ problems = config.tasks.select(&:scheduled?).filter_map do |t|
414
+ next if !t.enabled? && !sd.exists?(t.timer_unit)
415
+ next if t.enabled? && sd.enabled?(t.timer_unit)
416
+
417
+ "timer '#{t.timer_unit}' not #{t.enabled? ? 'enabled' : 'disabled'}"
418
+ end
419
+ check('timers match config', problems.empty?, problems.empty? ? nil : problems.join('; '))
420
+ end
421
+
422
+ def check_commands(config)
423
+ missing = config.tasks.filter_map do |t|
424
+ bin = Shellwords.split(t.command).first
425
+ executable_in_path?(bin) ? nil : t.name
426
+ end
427
+ check('commands resolvable', missing.empty?,
428
+ (missing.empty? ? nil : "not found: #{missing.join(', ')}"))
429
+ end
430
+
431
+ def executable_in_path?(bin)
432
+ return false if bin.nil? || bin.empty?
433
+ return File.executable?(bin) if bin.include?('/')
434
+
435
+ system("command -v #{bin} >/dev/null 2>&1")
436
+ rescue StandardError
437
+ false
438
+ end
439
+
440
+ def check_working_dirs(config)
441
+ bad = config.tasks.select(&:working_directory).reject do |t|
442
+ File.directory?(File.expand_path(t.working_directory))
443
+ end
444
+ check('working directories exist', bad.empty?,
445
+ (bad.empty? ? nil : "missing: #{bad.map(&:name).join(', ')}"))
446
+ end
447
+ end
448
+ end