wheneverd
Wheneverd is to systemd timers what the whenever gem is to cron.
Status
Pre-1.0, but working end-to-end for systemd user timers on Linux:
- Loads a Ruby schedule DSL file (default:
config/schedule.rb). - Renders systemd
.service/.timerunits (interval, calendar, and 5-field cron schedules). - Writes, diffs, shows, and deletes generated unit files (default:
~/.config/systemd/user). - Enables/starts/stops/disables/restarts timers via
systemctl --user. - Validates
OnCalendar=values withsystemd-analyze(optional unit verification). - Manages lingering via
loginctl(so timers can run while logged out).
Non-goals / not yet implemented:
- System-level units (
/etc/systemd/system) /systemctlwithout--user. - Non-systemd schedulers (cron, launchd, etc).
- Non-Linux platforms (no Windows/macOS support).
Expect the CLI and generated unit details to change until 1.0.
See FEATURE_SUMMARY.md for user-visible behavior, and CHANGELOG.md for release notes.
Installation
Add this line to your application's Gemfile:
gem "wheneverd"
And then execute:
bundle install
Usage
wheneverd --help
wheneverd init
wheneverd show
wheneverd status
wheneverd diff
wheneverd validate
wheneverd write
wheneverd delete
wheneverd activate
wheneverd deactivate
wheneverd reload
wheneverd current
wheneverd linger
Use wheneverd init to create a starter config/schedule.rb template (including examples for command and shell).
Minimal config/schedule.rb example
# frozen_string_literal: true
every "5m" do
command "echo hello"
end
every 1.day, at: "4:30 am" do
command "echo four_thirty"
end
Deploy a simple schedule (copy/paste)
From your project root (the default identifier is the current directory name):
# Install (skip if already in your Gemfile)
bundle add wheneverd
bundle install
# Write a schedule that appends a timestamp to ~/.cache/wheneverd-demo.log every minute
mkdir -p config
cat > config/schedule.rb <<'RUBY'
# frozen_string_literal: true
every "1m" do
shell "mkdir -p ~/.cache && date >> ~/.cache/wheneverd-demo.log"
end
RUBY
# Preview, write units, and enable/start the timer(s)
bundle exec wheneverd show
bundle exec wheneverd validate
bundle exec wheneverd write
bundle exec wheneverd activate
# Verify it’s installed and running
bundle exec wheneverd status
tail -n 5 ~/.cache/wheneverd-demo.log
# Stop/disable timers and remove generated unit files
bundle exec wheneverd deactivate
bundle exec wheneverd delete
Preview the generated units:
wheneverd show
Activating / deactivating (systemd)
After wheneverd write, use wheneverd activate to enable + start the generated timer units (by default, user units
in ~/.config/systemd/user):
wheneverd activate
Deactivate a timer:
wheneverd deactivate
After changing your schedule, rewrite units and restart the timer(s) to pick up changes:
wheneverd reload
User timers and lingering (loginctl enable-linger)
By default, wheneverd uses user systemd units (systemctl --user). On many systems, the per-user systemd instance
only runs while you are logged in. If you want timers to run after logout (or on boot without an interactive login),
enable lingering for your user:
wheneverd linger enable
This runs loginctl enable-linger "$USER" under the hood. If you see “Access denied”, your system may require admin
privileges (polkit policy); try:
sudo loginctl enable-linger "$USER"
Check whether lingering is enabled:
wheneverd linger status
To disable it later:
wheneverd linger disable
Syntax
Schedules are defined in a Ruby file (default: config/schedule.rb) and evaluated in a dedicated DSL context.
Note: schedule files are executed as Ruby. Do not run untrusted schedule code.
The core timer shape is:
every(period, at: nil) do
command "echo hello"
end
Long-running services can be managed from the same schedule with top-level
service entries:
service "worker",
shell: "bundle exec bin/worker",
restart: "always",
restart_sec: "5s",
service: {
"WorkingDirectory" => "/srv/apps/myapp/current",
"Environment" => "RAILS_ENV=production"
}
For calendar schedules, you can also pass multiple period symbols (or an array) to run the same jobs on multiple days:
every :tuesday, :wednesday, at: "12pm" do
command "echo midweek"
end
command
command(...) appends a oneshot ExecStart= job.
Accepted forms:
command("...")(String): inserted intoExecStart=as-is (after stripping surrounding whitespace).command(["bin", "arg1", "arg2"])(argv Array): formatted/escaped into a systemd-compatibleExecStart=string.
If you need shell features (pipes, redirects, globbing, env var expansion), either wrap it yourself, or use shell:
command "/bin/bash -lc 'echo hello | sed -e s/hello/hi/'"
command ["/bin/bash", "-lc", "echo hello | sed -e s/hello/hi/"]
shell
shell("...") is a convenience helper for the common /bin/bash -lc pattern:
shell "echo hello | sed -e s/hello/hi/"
every periods
Supported period forms:
- Interval strings:
"<n>s|m|h|d|w"(examples:"5m","1h") for monotonic timers (OnActiveSec=+OnUnitActiveSec=). - Duration objects:
1.second,1.minute,1.hour,1.day,1.week(and plurals), using the same interval semantics. - Symbol shortcuts:
:hour,:day,:month,:year(calendar schedules, mapped tohourly,daily,monthly,yearly)
:reboot(boot trigger, mapped toOnBootSec=1).- Day selectors:
:monday..:sunday, plus:weekdayand:weekend(calendar schedules; multiple day symbols supported). - Cron strings (5 fields), like
"0 0 27-31 * *"(calendar schedules).
Notes:
- Interval/duration schedules are monotonic (run relative to last execution), while calendar schedules are wall-clock
based. In particular,
every 1.dayis monotonic, butevery :dayis calendar-based. at:is only supported with calendar periods.every 1.day, at: ...is supported as a convenience and is treated as a daily calendar trigger.at:is not supported withevery :reboot.
at: times
at: may be a single string or an array of strings. Times are normalized at render time.
at: is not supported for interval strings (like "5m") or cron strings.
Accepted examples:
"4:30 am","6:00 pm","12pm""00:15"(24h)
Cron strings
Cron translation supports standard 5-field crontab strings (minute hour day-of-month month day-of-week), including:
- Wildcards, lists, ranges, and steps (
*,1,2,3,1-5,*/15,1-10/2) - Month and day-of-week names (
Jan,Mon) - Cron day-of-month vs day-of-week OR semantics (may expand into multiple
OnCalendar=lines)
Unsupported cron patterns raise an error at render time (e.g. non-5-field strings, @daily, L, W, #, ?).
CLI
Defaults:
- schedule path:
config/schedule.rb(override with--schedule PATH) - identifier: current directory name (override with
--identifier NAME) - unit dir:
~/.config/systemd/user(override with--unit-dir PATH)
Notes:
- Errors use Clamp-style
ERROR: ...formatting; add--verboseto include error details. wheneverd delete/wheneverd currentonly operate on units matching the identifier and the generated marker line.- Identifiers are sanitized for use in unit file names (non-alphanumeric characters become
-). - Unit basenames include a stable ID derived from the job’s trigger + command (reordering schedule blocks won’t rename units).
wheneverd write/wheneverd reloadprune previously generated units for the identifier by default (use--no-pruneto keep old units around).--unit-dircontrols where unit files are written/read/deleted;activate/deactivateuse systemd’s unit search path.wheneverd diffreturns exit status0when no differences are found, and1when differences are found.
Commands:
wheneverd init [--schedule PATH] [--force]writes a template schedule file.wheneverd show [--schedule PATH] [--identifier NAME]prints rendered units to stdout.wheneverd status [--identifier NAME] [--unit-dir PATH]printssystemctl --user list-timersandsystemctl --user statusfor installed timers/services.wheneverd diff [--schedule PATH] [--identifier NAME] [--unit-dir PATH]diffs rendered units vs unit files on disk.wheneverd validate [--schedule PATH] [--identifier NAME] [--verify]validates renderedOnCalendar=values viasystemd-analyze calendar(and with--verify, runssystemd-analyze --user verifyon temporary unit files).wheneverd write [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--dry-run] [--[no-]prune]writes units to disk (or prints paths in--dry-runmode).wheneverd delete [--identifier NAME] [--unit-dir PATH] [--dry-run]deletes previously generated units for the identifier.wheneverd activate [--schedule PATH] [--identifier NAME]runssystemctl --user daemon-reloadand enables/starts the timers/services.wheneverd deactivate [--schedule PATH] [--identifier NAME]stops and disables the timers/services.wheneverd reload [--schedule PATH] [--identifier NAME] [--unit-dir PATH] [--[no-]prune]writes units, reloads systemd, and restarts timers/services.wheneverd current [--identifier NAME] [--unit-dir PATH]prints the currently installed unit file contents from disk.wheneverd linger [--user NAME] [enable|disable|status]manages lingering vialoginctl(statusis the default).
Development
bundle install
# Run the CLI from this repo:
bundle exec exe/wheneverd --help
bundle exec rake test
bundle exec rake ci
bundle exec rake yard
# Also supported after `bundle install`:
rake ci
rake yard
Test runs write a coverage report to coverage/.
YARD docs are written to doc/ (and .yardoc/).