# cron-burgundy
A classy macOS cron manager that never* misses a job. Uses native launchd for scheduling and catches up on anything missed while your Mac was asleep.
> **"99% of the time, it runs every time."**
> It won't run if your computer is off
## Features
- **Human-readable schedules** - `'every 5 minutes'`, `'daily at 9am'`, `'on weekdays at 8:30am'`
- **Native launchd integration** - no daemon, each job gets its own plist
- **Missed job recovery** - catches up on jobs missed during sleep
- **Per-job logging** - separate log files for each job
- **Pause/unpause** - temporarily disable jobs without removing them
- **Built-in macOS utils** - notifications, text-to-speech, system sounds
- **Lock files** - prevents concurrent runs of the same job
- **Interactive CLI** - autocomplete job selection, multi-select for batch operations
## How It Works
1. Each job gets its own **launchd plist** with its schedule
2. **sleepwatcher** detects wake from sleep and runs missed jobs
3. No daemon, no background process - jobs fire on their own schedules
If your Mac was asleep when a job should have run, it fires on wake.
## Quick Start
```bash
# Install globally
npm install -g cron-burgundy
# Create a jobs file
cat > jobs.js << 'EOF'
export const jobs = [
{
id: 'hello',
schedule: 'every 5 minutes',
run: ({ utils }) => utils.notify('Hello', 'Job ran!')
}
]
EOF
# Register and sync to launchd
cronb sync jobs.js
# Done! Jobs run automatically on schedule
```
## Wake Detection Setup
To catch up on missed jobs when your Mac wakes from sleep:
```bash
# 1. Install sleepwatcher
brew install sleepwatcher
# 2. Create ~/.wakeup script
cat > ~/.wakeup << 'EOF'
#!/bin/bash
cron-burgundy check-missed
EOF
chmod +x ~/.wakeup
# 3. Start sleepwatcher service
brew services start sleepwatcher
```
Note: sleepwatcher may request "Input Monitoring" permission - this is NOT required for `~/.wakeup` to work. You can safely deny it.
### Focus Mode
If using `utils.notify()` and macOS Focus Mode is enabled, add **Script Editor** to allowed apps:
System Settings → Focus → [Your Focus] → Allowed Apps → Add → Script Editor
(`playSound()` and `speak()` work regardless of Focus Mode)
## CLI Commands
Available as `cron-burgundy` or `cronb`:
```
Usage: cronb [options] [command]
Commands:
list List all registered jobs with status
run [options] [jobId] Run a job manually (autocomplete if no arg)
logs View, list, or clear logs
pause [name] Pause a job or all jobs (interactive if no arg)
unpause [name] Unpause a job or all jobs (interactive if no arg)
sync [path] Register and sync a job file, or sync all registered files
clear [target] Unregister job files and remove from launchd (interactive if no arg)
status Check installed launchd plists
check-missed Check and run any missed jobs (called on wake)
Options:
-V, --version output the version number
-h, --help display help for command
```
### Logs subcommands
```bash
cronb logs # View runner log
cronb logs view # View specific job log
cronb logs view -t # Tail runner log (follow)
cronb logs view -t # Tail job log
cronb logs list # List all log file paths
cronb logs clear # Clear runner log
cronb logs clear # Clear specific job log
cronb logs clear all # Clear runner + all job logs
```
## Defining Jobs
Edit `jobs.js` in project root:
```javascript
export const jobs = [
{
id: 'my-job',
schedule: 'every 5 minutes', // Human-readable or cron syntax
enabled: true, // Optional, default: true
run: async ({ logger, utils, lastRun }) => {
logger.log('Starting...')
// Your job logic here
utils.notify('Job done', 'Completed successfully')
logger.log('Done!')
}
}
]
```
### Available Utils
Jobs receive a `utils` object with macOS helpers:
```javascript
utils.notify(title, message, { sound: true }) // macOS notification
utils.speak('Hello world') // Text-to-speech
utils.playSound('Ping') // System sound (Ping, Pop, Glass, Frog, etc.)
```
### Job Context
The `run` function receives:
- `logger` - Job-specific logger (`logger.log()`, `logger.error()`)
- `utils` - macOS utilities (notify, speak, playSound)
- `lastRun` - Date of last successful run (or null)
## Schedule Syntax
### Human-Readable (Recommended)
```javascript
// Basic intervals
schedule: 'every minute'
schedule: 'every hour'
schedule: 'every day'
schedule: 'hourly'
schedule: 'daily'
schedule: 'weekly'
schedule: 'monthly'
schedule: 'yearly'
// Custom intervals
schedule: 'every 5 minutes'
schedule: 'every 2 hours'
schedule: 'every 3 days'
schedule: 'every 2 weeks'
schedule: 'every 6 months'
// Shorthand intervals (without "every")
schedule: '5 minutes'
schedule: '2 hours'
schedule: '1 week'
// Specific times
schedule: 'at 9:30'
schedule: 'at 2:00 pm'
schedule: 'at 14:15'
schedule: 'noon'
schedule: 'midnight'
schedule: 'morning' // 9am
schedule: 'evening' // 6pm
// Days of week
schedule: 'monday'
schedule: 'friday'
schedule: 'weekdays'
schedule: 'weekends'
schedule: 'on monday at 9:00'
schedule: 'on friday at 5:30 pm'
schedule: 'on monday,wednesday,friday at 8:00 am'
schedule: 'on tuesday,thursday at 2:30 pm'
schedule: 'on weekdays at 8:30 am'
schedule: 'on weekends at 10:00 am'
// Day of month
schedule: 'on 1st of month at 9:00'
schedule: 'on 15th of month at 12:00'
schedule: 'on 31st of month at 2:00 pm'
schedule: 'first day of month'
schedule: 'middle of month'
// Business patterns
schedule: 'business hours' // 9am-5pm weekdays
// Special
schedule: 'never' // Feb 30th (never runs)
schedule: 'reboot' // @reboot
schedule: 'startup' // @reboot
```
### Standard Cron
```javascript
schedule: '0 9 * * *' // 9am daily
schedule: '*/5 * * * *' // Every 5 minutes
schedule: '0 0 * * 0' // Midnight on Sundays
schedule: '30 14 1 * *' // 2:30pm on 1st of month
schedule: '0 9-17 * * 1-5' // Every hour 9am-5pm weekdays
schedule: '15 2,14 * * *' // 2:15am and 2:15pm daily
```
### Interval (milliseconds)
The minimum interval is 10 seconds, per macOS launchd requirements.
```javascript
interval: 60 * 1000 // Every minute
interval: 60 * 60 * 1000 // Every hour
interval: 24 * 60 * 60 * 1000 // Every day
```
## Disabling Jobs
Set `enabled: false` to disable a job, then run `sync`:
```javascript
{
id: 'my-job',
enabled: false, // Job won't run
schedule: 'daily',
run: async () => { ... }
}
```
```bash
cronb sync # Removes disabled job from launchd
```
## Files
| Path | Description |
|------|-------------|
| `~/.cron-burgundy/state.json` | Last run times for each job |
| `~/.cron-burgundy/runner.log` | Main execution log |
| `~/.cron-burgundy/jobs/*.log` | Per-job logs |
| `~/.cron-burgundy/locks/` | Job lock files (prevent concurrent runs) |
| `~/Library/LaunchAgents/com.cron-burgundy.job.*.plist` | Per-job launchd configs |
| `~/.wakeup` | sleepwatcher script for wake detection |
## Architecture
```
┌─────────────────┐ ┌─────────────────┐
│ launchd │ │ sleepwatcher │
│ (per-job plist) │ │ (~/.wakeup) │
└────────┬────────┘ └────────┬────────┘
│ │
│ fires on schedule │ fires on wake
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ cron-burgundy │
│ - Acquires job lock │
│ - Runs job function │
│ - Updates state.json │
│ - Releases lock │
└─────────────────────────────────────────┘
```
## DarkWake Behavior
Jobs can run even when your Mac appears asleep. macOS uses **DarkWake** - brief wake periods where background tasks run while the display stays off.
### What triggers DarkWake
- **Do Not Disturb schedule** - DND end time wakes the system
- **Power Nap** - periodic maintenance wakes (if enabled)
- **Scheduled alarms** - Calendar events, reminders
- **Push notifications** - iCloud, Messages, etc.
- **Network activity** - Wake on LAN, Find My Mac
### How it affects your jobs
If a `StartCalendarInterval` job is scheduled during a DarkWake, launchd runs it. This means:
- Jobs may run at their exact scheduled time even while "asleep"
- Audio (`utils.speak()`, `utils.playSound()`) will play
- Logs show the actual scheduled time, not wake time
### Checking wake events
```bash
pmset -g log | grep -E "(Wake|Sleep)" | tail -20
```
Look for `DarkWake` entries to see when your Mac briefly woke.