Skip to content

Instantly share code, notes, and snippets.

@briandfoy
Last active January 5, 2026 09:32
Show Gist options
  • Select an option

  • Save briandfoy/2d38251c0c2a5648a0655ce0f6d49fcc to your computer and use it in GitHub Desktop.

Select an option

Save briandfoy/2d38251c0c2a5648a0655ce0f6d49fcc to your computer and use it in GitHub Desktop.
(Perl) convert crontab to launchd files
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