Skip to content

Instantly share code, notes, and snippets.

@brenorb
Created March 26, 2026 14:52
Show Gist options
  • Select an option

  • Save brenorb/fb1b9adade74e600ee57bf94f3202717 to your computer and use it in GitHub Desktop.

Select an option

Save brenorb/fb1b9adade74e600ee57bf94f3202717 to your computer and use it in GitHub Desktop.
macOS launchd daily catch-up template with persisted state and RunAtLoad fallback

Launchd Daily Catch-Up Template

This is a small template for the common macOS pattern:

  • run a task every night at a fixed local time
  • if the Mac was asleep, run on wake
  • if the Mac was off and later boots/logs in, run once as catch-up
  • avoid custom hourly cron polling

What Is In Here

Three files:

  • run-daily-catchup.sh
    • the generic runner
    • tracks the last successful daily window
    • coalesces missed days into a single catch-up execution
  • install-daily-catchup.sh
    • writes a user LaunchAgent plist
    • bootstraps it with launchctl
  • README.md
    • this usage note

How It Works

The LaunchAgent uses:

  • StartCalendarInterval
    • tries to run at the desired hour/minute every day
  • RunAtLoad
    • runs when the agent is loaded again, such as after login/boot

The runner script adds the missing piece:

  • it computes the current "due window"
  • if the machine comes back before today's scheduled time, the due window is yesterday
  • if the machine comes back after today's scheduled time, the due window is today
  • if that due window already succeeded, it exits
  • otherwise it runs the payload and records success

That means:

  • sleep at 22:00, scheduled for 23:00, wake at 08:00
    • it runs at wake
  • machine off at 22:00, scheduled for 23:00, login at 08:00
    • RunAtLoad runs the agent
    • the runner sees that yesterday never completed
    • it runs once as catch-up

Install Example

First make your real payload script executable. The payload should do the actual work and exit:

  • 0 on success
  • non-zero on failure

Then install the agent:

/Users/breno/Documents/code/PROJECTS/hackathon/arc2-2026/templates/launchd-daily-catchup/install-daily-catchup.sh \
  --label com.example.nightly-report \
  --hour 23 \
  --minute 0 \
  --tz America/Sao_Paulo \
  --payload /Users/me/bin/nightly-report.sh \
  --state-dir /Users/me/.local/state/nightly-report \
  --logs-dir /Users/me/Library/Logs/nightly-report

Inspect And Remove

Inspect:

launchctl print gui/$(id -u)/com.example.nightly-report

Force a run now:

launchctl kickstart -k gui/$(id -u)/com.example.nightly-report

Remove:

launchctl bootout gui/$(id -u)/com.example.nightly-report
rm ~/Library/LaunchAgents/com.example.nightly-report.plist

Limits

  • This is a per-user LaunchAgent, so it catches up when your user session loads again.
  • It does not wake a powered-off machine by itself.
  • If your payload depends on internet, the payload should handle "network still unavailable" and exit non-zero so it retries on the next trigger.
  • It intentionally coalesces multiple missed days into one execution, which is usually what you want for "nightly maintenance" style tasks.
#!/bin/zsh
set -euo pipefail
label=""
hour=""
minute=""
tz_name=""
payload=""
state_dir=""
logs_dir=""
usage() {
cat <<'EOF'
Usage:
install-daily-catchup.sh \
--label LABEL \
--hour HOUR \
--minute MINUTE \
--tz TZ_NAME \
--payload ABSOLUTE_PAYLOAD_PATH \
--state-dir ABSOLUTE_STATE_DIR \
--logs-dir ABSOLUTE_LOGS_DIR
Example:
./install-daily-catchup.sh \
--label com.example.nightly-report \
--hour 23 \
--minute 0 \
--tz America/Sao_Paulo \
--payload /Users/me/bin/nightly-report.sh \
--state-dir /Users/me/.local/state/nightly-report \
--logs-dir /Users/me/Library/Logs/nightly-report
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--label)
label="$2"
shift 2
;;
--hour)
hour="$2"
shift 2
;;
--minute)
minute="$2"
shift 2
;;
--tz)
tz_name="$2"
shift 2
;;
--payload)
payload="$2"
shift 2
;;
--state-dir)
state_dir="$2"
shift 2
;;
--logs-dir)
logs_dir="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
[[ -n "$label" ]] || { echo "--label is required" >&2; exit 1; }
[[ -n "$hour" ]] || { echo "--hour is required" >&2; exit 1; }
[[ -n "$minute" ]] || { echo "--minute is required" >&2; exit 1; }
[[ -n "$tz_name" ]] || { echo "--tz is required" >&2; exit 1; }
[[ -n "$payload" ]] || { echo "--payload is required" >&2; exit 1; }
[[ -n "$state_dir" ]] || { echo "--state-dir is required" >&2; exit 1; }
[[ -n "$logs_dir" ]] || { echo "--logs-dir is required" >&2; exit 1; }
[[ "$payload" = /* ]] || { echo "--payload must be an absolute path" >&2; exit 1; }
[[ "$state_dir" = /* ]] || { echo "--state-dir must be an absolute path" >&2; exit 1; }
[[ "$logs_dir" = /* ]] || { echo "--logs-dir must be an absolute path" >&2; exit 1; }
script_dir="$(cd "$(dirname "$0")" && pwd)"
runner="$script_dir/run-daily-catchup.sh"
[[ -x "$runner" ]] || { echo "Runner is not executable: $runner" >&2; exit 1; }
mkdir -p "$state_dir" "$logs_dir" "$HOME/Library/LaunchAgents"
plist_path="$HOME/Library/LaunchAgents/$label.plist"
runner_log="$logs_dir/runner.log"
agent_stdout="$logs_dir/agent.stdout.log"
agent_stderr="$logs_dir/agent.stderr.log"
cat >"$plist_path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$label</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>$runner</string>
<string>--label</string>
<string>$label</string>
<string>--hour</string>
<string>$hour</string>
<string>--minute</string>
<string>$minute</string>
<string>--tz</string>
<string>$tz_name</string>
<string>--state-dir</string>
<string>$state_dir</string>
<string>--payload</string>
<string>$payload</string>
<string>--log-file</string>
<string>$runner_log</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>$hour</integer>
<key>Minute</key>
<integer>$minute</integer>
</dict>
<key>StandardOutPath</key>
<string>$agent_stdout</string>
<key>StandardErrorPath</key>
<string>$agent_stderr</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$plist_path"
cat <<EOF
Installed launchd agent:
label: $label
plist: $plist_path
payload: $payload
state dir: $state_dir
logs dir: $logs_dir
Useful commands:
launchctl print gui/$(id -u)/$label
launchctl kickstart -k gui/$(id -u)/$label
launchctl bootout gui/$(id -u)/$label
EOF
#!/bin/zsh
set -euo pipefail
label=""
hour=""
minute=""
tz_name=""
state_dir=""
payload=""
log_file=""
usage() {
cat <<'EOF'
Usage:
run-daily-catchup.sh \
--label LABEL \
--hour HOUR \
--minute MINUTE \
--tz TZ_NAME \
--state-dir STATE_DIR \
--payload PAYLOAD_SCRIPT \
[--log-file LOG_FILE]
Notes:
- The payload must be an executable script or binary.
- This runner coalesces missed days into a single catch-up run.
- It marks success only after the payload exits with code 0.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--label)
label="$2"
shift 2
;;
--hour)
hour="$2"
shift 2
;;
--minute)
minute="$2"
shift 2
;;
--tz)
tz_name="$2"
shift 2
;;
--state-dir)
state_dir="$2"
shift 2
;;
--payload)
payload="$2"
shift 2
;;
--log-file)
log_file="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
[[ -n "$label" ]] || { echo "--label is required" >&2; exit 1; }
[[ -n "$hour" ]] || { echo "--hour is required" >&2; exit 1; }
[[ -n "$minute" ]] || { echo "--minute is required" >&2; exit 1; }
[[ -n "$tz_name" ]] || { echo "--tz is required" >&2; exit 1; }
[[ -n "$state_dir" ]] || { echo "--state-dir is required" >&2; exit 1; }
[[ -n "$payload" ]] || { echo "--payload is required" >&2; exit 1; }
[[ -x "$payload" ]] || { echo "Payload is not executable: $payload" >&2; exit 1; }
mkdir -p "$state_dir"
[[ -z "$log_file" ]] || mkdir -p "$(dirname "$log_file")"
log() {
local message="$1"
local stamp
stamp="$(TZ="$tz_name" date '+%Y-%m-%d %H:%M:%S %Z')"
if [[ -n "$log_file" ]]; then
printf '[%s] %s\n' "$stamp" "$message" >>"$log_file"
else
printf '[%s] %s\n' "$stamp" "$message"
fi
}
if ! [[ "$hour" =~ ^[0-9]+$ && "$hour" -ge 0 && "$hour" -le 23 ]]; then
echo "Invalid --hour: $hour" >&2
exit 1
fi
if ! [[ "$minute" =~ ^[0-9]+$ && "$minute" -ge 0 && "$minute" -le 59 ]]; then
echo "Invalid --minute: $minute" >&2
exit 1
fi
readonly state_file="$state_dir/last-successful-window.txt"
readonly meta_file="$state_dir/last-run-meta.txt"
readonly lock_dir="$state_dir/.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
log "Another run is already in progress; exiting."
exit 0
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT
readonly current_hour="$(TZ="$tz_name" date '+%H')"
readonly current_minute="$(TZ="$tz_name" date '+%M')"
readonly current_total_minutes="$((10#$current_hour * 60 + 10#$current_minute))"
readonly scheduled_total_minutes="$((10#$hour * 60 + 10#$minute))"
if (( current_total_minutes >= scheduled_total_minutes )); then
due_window="$(TZ="$tz_name" date '+%Y-%m-%d')"
else
due_window="$(TZ="$tz_name" date -v-1d '+%Y-%m-%d')"
fi
last_successful_window=""
if [[ -f "$state_file" ]]; then
last_successful_window="$(<"$state_file")"
fi
if [[ -n "$last_successful_window" && "$last_successful_window" == "$due_window" ]]; then
log "Window $due_window already completed for $label; skipping."
exit 0
fi
if [[ -n "$last_successful_window" && "$last_successful_window" > "$due_window" ]]; then
log "State file is ahead of due window ($last_successful_window > $due_window); skipping."
exit 0
fi
log "Starting payload for due window $due_window."
log "Payload: $payload"
start_epoch="$(date '+%s')"
if "$payload"; then
end_epoch="$(date '+%s')"
duration="$((end_epoch - start_epoch))"
printf '%s\n' "$due_window" >"$state_file"
{
printf 'label=%s\n' "$label"
printf 'due_window=%s\n' "$due_window"
printf 'completed_at=%s\n' "$(TZ="$tz_name" date '+%Y-%m-%dT%H:%M:%S%z')"
printf 'duration_seconds=%s\n' "$duration"
printf 'payload=%s\n' "$payload"
} >"$meta_file"
log "Payload completed successfully in ${duration}s."
else
status="$?"
log "Payload failed with exit code $status."
exit "$status"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment