Last active
January 5, 2026 09:32
-
-
Save briandfoy/2d38251c0c2a5648a0655ce0f6d49fcc to your computer and use it in GitHub Desktop.
(Perl) convert crontab to launchd files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use v5.42; | |
| use List::Util qw(zip); | |
| use Mojo::Template; | |
| use Set::CrossProduct; | |
| use Storable qw(dclone); | |
| =head1 NAME | |
| crontab2launchd - rough conversion of crontab lines to launchd files | |
| =head1 SYNOPSIS | |
| Install some CPAN modules: | |
| % cpan Mojolicious Set::CrossProduct Set::Crontab | |
| Run the program: | |
| % crontab2launchd | |
| =head1 DESCRIPTION | |
| I needed to convert some crontab entries to launchd for various | |
| reasons, but launchd is a bit annoying since you need to specify every | |
| combination of all the time components. | |
| The program gets its input from `crontab -l`, then processes it. | |
| This includes all the environment values from the crontab, so check | |
| each file for what you actually need. Some you might use on the | |
| command line, and others might be there for the script itself. | |
| There's quite a bit that happens in the script to parse the crontab | |
| entries, Then much more happens in the template, which has its own | |
| internal processing. | |
| None of this is designed to be perfect. It's to get close to what I | |
| need in launchd, especially with the time components. From there, I | |
| hand-edit the rest. | |
| =head1 SOURCE AVAILABILITY | |
| This source is in GitHub as a gist: | |
| https://gist.github.com/briandfoy/2d38251c0c2a5648a0655ce0f6d49fcc | |
| =head1 AUTHOR | |
| brian d foy, C<< <briandfoy@pobox.com> >>. | |
| =head1 COPYRIGHT AND LICENSE | |
| Copyright © 2025-2026, brian d foy <briandfoy@pobox.com>. All rights reserved. | |
| This program is free software; you can redistribute it and/or modify | |
| it under the terms of the Artistic License 2.0. | |
| =cut | |
| my $field = qr/ | |
| [\*\d]+ # a * or digits | |
| (?:[-\/][\*\d]+)? # optional range or step | |
| (?:,[\*\d]+(?:[-\/][\*\d]+)?)* # optional comma-separated list | |
| /x; | |
| my $cron_regex = qr/ | |
| \A | |
| ( (?: $field \s+ ){5} ) | |
| ( .* ) | |
| \z | |
| /x; | |
| open my $pfh, '-|', 'crontab', '-l'; | |
| my %cron_env; | |
| while( <$pfh> ) { | |
| next if /\A \h* \#/x; # skip comment lines | |
| next unless /\S/; | |
| $_ = trim($_); | |
| if( /\A \h* (\w+) \h* = \h* (.*)/ax ) { | |
| my( $key, $value ) = ( $1, $2 ); | |
| warn "key <$key> redefined with value <$value>, previously <$cron_env{$key}>\n" | |
| if exists $cron_env{$key}; | |
| $cron_env{$key} = $value; | |
| next; | |
| } | |
| if( /$cron_regex/ ) { | |
| state $count = 0; | |
| my @captures = @{^CAPTURE}; | |
| my( $schedule ) = trim( shift @captures ); | |
| my( $command ) = trim( pop @captures ); | |
| $schedule = make_launchd_schedule( $schedule, $command, \%cron_env ); | |
| my $set = Set::CrossProduct->new( $schedule ); | |
| open my $fh, '>:utf8', sprintf 'launchd-%03d.plist', $count++; | |
| print {$fh} cook_template( $command, $set, \%cron_env ); | |
| close $fh; | |
| } | |
| else { | |
| warn "Did not match cron line\n"; | |
| next; | |
| } | |
| } | |
| sub dumper { state $rc = require Data::Dumper; Data::Dumper->new([@_])->Indent(1)->Sortkeys(1)->Terse(1)->Useqq(1)->Dump } | |
| sub make_launchd_schedule ( $schedule, $command, $env = {} ) { | |
| state @ranges = ( [ 'Minute', 0..59], ['Hour', 0..23], ['Day', 1..31], ['Month', 1..12], ['Weekday', 0..6] ); | |
| my @divisions = split /\h+/, $schedule; | |
| my %schedule = map { parse_division( $_->@* ) } zip \@divisions, \@ranges; | |
| return \%schedule; | |
| } | |
| sub parse_division ($division, $range) { | |
| state $rc = require Set::Crontab; | |
| return if $division eq '*'; | |
| my( $label, @numbers ) = $range->@*; | |
| my @list = Set::Crontab->new($division, \@numbers)->list; | |
| return ($label, \@list); | |
| } | |
| sub cook_template ( $command, $set, $cron_env = {} ) { | |
| state $template = do { local $/; <DATA> }; | |
| return Mojo::Template->new->vars(1)->render( $template, { | |
| command => $command, | |
| label => 'Some label', | |
| set => $set, | |
| env => $cron_env, | |
| }); | |
| } | |
| =pod | |
| <dict> | |
| <key>Hour</key> | |
| <integer>17</integer> | |
| <key>Minute</key> | |
| <integer>31</integer> | |
| </dict> | |
| =cut | |
| __END__ | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <!-- | |
| <%= $command %> | |
| --><% | |
| use builtin qw(indexed); | |
| my @order = qw( Minute Hour Day Month Weekday ); | |
| my %order = reverse indexed @order; | |
| %><plist version="1.0"> | |
| <dict> | |
| <key>Label</key> | |
| <string><%= $label %></string> | |
| <key>EnvironmentVariables</key> | |
| <dict><% foreach my( $k, $v ) ( $env->%* ) { %> | |
| <key><%= $k %></key><value><%= $v %></value><% } %> | |
| </dict> | |
| <key>ProgramArguments</key> | |
| <array> | |
| <string>/bin/sh</string> | |
| <string>-c</string> | |
| <string><%= $command %></string> | |
| </array> | |
| <key>WorkingDirectory</key> | |
| <string>/path/to/your/directory</string> | |
| <key>StartCalendarInterval</key> | |
| <% foreach my $hash ( $set->combinations->@* ) { %> | |
| <dict><% foreach my $key ( sort { $order{$a} <=> $order{$b} } keys $hash->%* ) { %> | |
| <key><%= $key %></key><value><%= $hash->{$key} %></value><% } %> | |
| </dict><% } %> | |
| <key>RunAtLoad</key><true/> | |
| </dict> | |
| </plist> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment