Skip to content

Converting crontabs

runwisp import cron turns a crontab into a runwisp.toml you own, annotated with anything that needed a human’s attention. There are two reasons you’d land here, and they’re different:

  • You already took over and want a job as hand-written TOML in git. Skip to Graduating a job into your own config and promote it. Cron is gone, so nothing double-fires — the easy path.
  • You’re converting by hand — on macOS or a box without systemd, where takeover can’t retire cron for you, or because you just want a clean generated TOML from the start. import cron copies your jobs; you then retire the old crontab lines yourself.

Pipe your current crontab in, or point it at a file:

Terminal window
crontab -l | runwisp import cron # whatever's installed for you
runwisp import cron /etc/crontab # a specific file

Given a crontab like:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin
# Nightly database backup
30 2 * * * /usr/local/bin/backup.sh --full
@reboot /opt/app/warmup
*/5 * * * * /opt/healthcheck.sh

you get an annotated runwisp.toml on stdout:

#:schema https://docs.runwisp.com/config.schema.json
# Generated by `runwisp import`. Review the notes below, then
# validate with `runwisp validate`.
[tasks.backup]
description = "Nightly database backup"
cron = "30 2 * * *"
shell = "/bin/bash"
env_base = "clean"
working_dir = "~"
catch_up = "skip" # cron never re-fires a missed tick
on_overlap = "queue" # cron would overlap instead of queueing
run = "/usr/local/bin/backup.sh --full"
[tasks.backup.env]
PATH = "/usr/local/bin:/usr/bin"
# @reboot — runs once each time the daemon starts.
[tasks.warmup]
run_on_start = true
shell = "/bin/bash"
env_base = "clean"
working_dir = "~"
on_overlap = "queue" # cron would overlap instead of queueing
run = "/opt/app/warmup"
[tasks.warmup.env]
PATH = "/usr/local/bin:/usr/bin"
[tasks.healthcheck]
cron = "*/5 * * * *"
shell = "/bin/bash"
env_base = "clean"
working_dir = "~"
catch_up = "skip" # cron never re-fires a missed tick
on_overlap = "queue" # cron would overlap instead of queueing
run = "/opt/healthcheck.sh"
[tasks.healthcheck.env]
PATH = "/usr/local/bin:/usr/bin"

Each task carries the crontab’s SHELL and PATH itself rather than a shared [defaults] block, so it stands alone and your own global settings stay untouched. The env_base, working_dir, catch_up and on_overlap lines reproduce cron’s execution model, not the daemon’s — see How cron maps to TOML. (warmup gets no catch_up: a @reboot job has no ticks to miss.)

On stderr — so it stays out of the TOML you’re piping somewhere — you get an account of every job:

Imported crontab → 3 tasks
✓ backup 30 2 * * *
/usr/local/bin/backup.sh --full
✓ warmup @reboot
/opt/app/warmup
✓ healthcheck */5 * * * *
/opt/healthcheck.sh
Notes:
! crontab sets [email protected]. crond mails a job's output; RunWisp
notifies on the event instead, so the closest equivalent is a sendmail
notifier through the same MTA. Add to your root runwisp.toml:
[[notifier]]
id = "mta"
type = "sendmail"
from = "runwisp@localhost"
then put notify_on_failure = ["mta"] on the tasks you want mail from. Note
the difference: crond mailed any output at all, this mails failures.
Review the TOML above, save it as runwisp.toml, then run `runwisp validate`.
! cron still runs these jobs. Comment them out (`crontab -e`, or move the
file out of /etc/cron.d) before starting RunWisp, or each one runs twice.

Every line of your crontab gets a row showing the command that will actually run, wrapped if it’s long and never cut short. The mark tells you how it landed:

Mark Means
Mapped cleanly.
~ Imported, but something changed — the row says what.
! Needs a fix from you before this config will load.
- Skipped, because your runwisp.toml already defines it.

Anything about the file rather than one job — a MAILTO, an unresolvable [include] — lands in Notes: at the bottom. Nothing is dropped without a line. The closing ! about double-firing is the one thing this route asks of you; see Retiring the crontab.

When it looks right, save it and check it parses:

Terminal window
crontab -l | runwisp import cron -o runwisp.toml
runwisp validate

-o gives you one flat file, perfect when you’re starting fresh. If you already have a runwisp.toml you’ve been hand-writing, use --write instead — it never touches what you wrote:

Terminal window
crontab -l | runwisp import cron --write --dry-run # look first
crontab -l | runwisp import cron --write # then do it

--dry-run prints the same summary and names every file a real run would touch, without touching any of them:

Would stage 3 tasks in /etc/runwisp/runwisp.d/imported.toml
Would create /etc/runwisp/runwisp.toml and wire it to load runwisp.d/*.toml.
Not checked: whether these merge with the tasks you already have.
A real run checks that, and rolls both files back if it fails.
! cron still runs these jobs. Comment them out (`crontab -e`, or move the
file out of /etc/cron.d) before starting RunWisp, or each one runs twice.
Nothing was written.

The real run installs the import in a two-tier layout:

runwisp.toml # yours — hand-authored, keep it in git
runwisp.d/imported.toml # machine-owned — the importer writes this

Your root file is created (or its [daemon].include wired) to load runwisp.d/*.toml, and the imported jobs land in the staging file. The importer only ever owns runwisp.d/imported.toml, so you can re-run the import without worrying what it’ll do to your own tasks.

A name clash doesn’t break anything: if your config already defines that name with the same command, the job is skipped as already-yours (a - row); with a different command, the import is renamed — backup becomes backup-2 — and the row says so. Both files are written as one unit, so any failure rolls both back rather than leaving your daemon half-updated.

Imported tasks report "source": "staged" in runwisp list --json and status --json — shorthand for “came from an import, not native TOML yet”. It’s a label, not a limitation: staged tasks are scheduled and run like any other.

promote turns a job RunWisp already runs into a block in your own runwisp.toml:

Terminal window
runwisp promote backup # this one
runwisp promote --all # all of them

It works the same on a job read live via include_cron and on a staged import; --all covers both, but what “move” means differs:

  • From a live crontab: the definition is copied into your runwisp.toml, and the exact source line is commented out in the crontab it came from — the same file include_cron read, whether that’s your user spool (crontab -e) or an /etc/cron.d drop-in. That’s not just tidying. A job is only ever “held” (RunWisp standing down while a live cron daemon runs it) while it’s cron-sourced, so the instant it becomes native RunWisp would schedule it — and a live cron would keep firing the line too. Commenting the line out stops cron from firing it while leaving it visible, with a note pointing at where the job moved. Promote refuses, writing nothing on either side, if that line has changed or gone missing since the config was loaded — try again once the crontab has settled.
  • From a staged import: staging is a waiting room. promote moves the block out of runwisp.d/imported.toml into your runwisp.toml, comments and all — including any # TODO the import left. Nothing is re-generated, so nothing gets reformatted or lost. When the last staged job is promoted, the empty staging file is cleaned up and you’re left with a single hand-maintained runwisp.toml.

From then on the job is yours: it reports no source, and a later re-import won’t touch it. What a runwisp reload reports depends on which move it was. A staged promotion — or a live-crontab one where you’d already retired cron, so RunWisp was scheduling the job anyway — changes nothing about what runs: the reload reports no task changes and only refreshes the provenance label. Promoting a job while cron is still live is a real handover — it was held, and now it isn’t — so the reload reports it as a changed task and RunWisp starts scheduling it. Either way, --reload does the move and the reload in one step.

An import copies your jobs; it never touches your crontab. A copy has no link back to the crontab line it came from, so — unlike a job read live through include_cron — RunWisp can’t recognise it as the same one cron runs and stand it down. Starting the daemon with cron still enabled means two schedulers firing each job. So take the imported jobs away from cron by hand:

Terminal window
crontab -l > ~/crontab.backup # keep a copy, always
crontab -e # comment out the lines you imported

For a system crontab, move the file instead of editing it — cron picks up /etc/cron.d by directory listing:

Terminal window
sudo mv /etc/cron.d/myjobs /root/myjobs.crontab.backup

Cutting over gradually works too: comment out one job, let RunWisp own it for a few days, then do the next. Just don’t leave a job enabled in both places — one owner per job keeps the schedules honest.

/etc/crontab and files in /etc/cron.d put a user between the schedule and the command. RunWisp maps that column to the task’s user automatically — from the path or the # ... user command header. Force it either way with --system / --system=false when a piped crontab is ambiguous; the mapping reference covers what happens when a line is missing its user column.

runwisp import cron [FILE] reads the file you name, or stdin:

Flag What it does
--output, -o Write the TOML to this standalone file instead of stdout.
--write Install into the two-tier layout next to the --config path.
--force Overwrite the -o target without prompting.
--dry-run Print the summary and name every file a real run would touch, then stop.
--quiet Drop the clean rows from the summary, keep anything that needs a fix.
--system Force system-crontab parsing (the user column). --system=false forces per-user; auto-detected when unset.

--dry-run and --quiet contradict each other and are rejected together — a dry run writes nothing, so the summary is all it has to show you.

runwisp promote [TASK...] takes:

Flag What it does
--all Promote every promotable task — staged and crontab-sourced.
--reload Reload the running daemon afterwards so it picks the move up — clears the provenance marker, and for a job promoted while cron is still live also hands its schedule to RunWisp.
--dry-run Print the blocks that would move and write nothing.
  • import cron never modifies the source. Reading and copying your crontab leaves the file untouched, which is why retiring it is yours to do. promote is the one exception — it comments out the exact cron line of the job it graduates (see Graduating a job).
  • It never edits your hand-written tasks. --write only owns runwisp.d/imported.toml.
  • It never writes daemon-wide settings. A MAILTO becomes a notifier block in the summary for you to paste, not a [[notifier]] the importer adds.
  • It doesn’t guess. Anything that can’t map cleanly gets a # TODO and a row in the summary — see What needs a human.