Distributing Dancer Apps as Modules

September 11th, 2012
PerlDancer

Distributing Dancer Apps as Modules

Something that has been bugging me for a long time is how Dancer apps (or Catalyst apps for what matters) can’t, generally-speaking, be installed like regular modules or applications because of their configuration files, static files and whatnots. What I would dearly love is to be able to do

$ cpanm App::Chorus
$ chorus.pl prez.mkd

and have stuff, y’know, just work. (If you wonder what Chorus is, look here)

So I decided it was high time to try and have a stab at a potential solution.

The mad scheme I came up with is based on two tricks. The first one is File::ShareDir, to install all the templates, configs and other files required by the app alongside the distribution. The second one is to use the my_dist_data function of File::HomeDir to copy all those goodies in a user-specific location (because we don’t want users to compete and clobber each other). For them to work together, we’ll need to tweak our Dancer app files a little bit, and we’ll need a small dash of magic called Dancer::Local.

Grooming the Application

Let’s assume that we are starting with a typical Dancer app called Foo. First thing to do to make it installable is a no-brainer: rename the default script from app.pl to foo.pl. The script itself will have to be modified to use Dancer::Local:

#!/usr/bin/env perl

# important: must be before 'use Dancer'
use Dancer::Local 'Foo';

use Dancer;

use Foo;
dance;

Although that’s not quite true. We could also leave the script alone and just latter call it as

$ perl -IDancer::Local=Foo `which foo.pl`

But that’s not as appealing, so let’s not do that unless we are forced to.

The last step (yes, already!) consists of adding the File::ShareDir support to the building process. I prefer Module::Build over ExtUtils::MakeMaker, so I went with:

package MyBuild;

use strict; 
use warnings;

use base qw/ Module::Build /;

use File::Copy::Recursive qw/ rcopy /;

$File::Copy::Recursive::CPRFComp = 1;

my @to_copy = qw/
    config.yml
    logs
    environments
    public
    views
    REMOVE_ME
/;

unless ( -d 'share' ) {
    mkdir 'share';

    rcopy( $_, 'share' ) for grep { -e $_ } @to_copy;
}

1;

Mind you, I could have just thrown all the files in share in the first place, but as I’m the type of guy who wants his cake and munch on it too, I wanted to keep the default layout as close as possible to what already exist. The eagle-eyed might also have noticed the mysterious REMOVE_ME file. More details on that in a few paragraphs.

And our distribution is now ready to be installed.

Dancer::Local, aka the Man Behind the Curtain

What we did so far is to ensure that if the application is installed, it is installed with all its components. Now we need a little helping elf to make sure that the app knows how to find and use those components, wherever it’s called from. That’ll be the job of Dancer::Local:

package Dancer::Local;

use 5.10.0;

use strict;
use warnings;

use File::ShareDir 'dist_dir';
use File::HomeDir;
use File::Copy::Recursive qw/ dircopy /;
use File::Path qw/ make_path /;
use List::MoreUtils qw/ after_incl /;

sub import {
    my( $self, $dist ) = @_;

    my $appdir;

    if ( my @to_install = after_incl { $_ eq '--install' } @ARGV ) {
        make_path( $to_install[1] ) if defined $to_install[1];

        $appdir = $to_install[1] // '.';

        dircopy( dist_dir($dist) => $appdir );

        say "installed shared file for '$dist' in '$appdir'";
    }
    else {
        no warnings 'uninitialized';

        $appdir = $ENV{DANCER_APPDIR} 
            || ( '.' x -f 'config.yml' )
            || File::HomeDir->my_dist_data($dist) 
            || create_local_copy($dist);
    }

    $ENV{DANCER_APPDIR} = $appdir;

    if ( open my $fh, "$appdir/REMOVE_ME" ) {
        say "\n", <$fh>, "\n", 
            "*** review the configuration files in '$appdir'\n",
            "*** delete '$appdir/REMOVE_ME',\n",
            "*** and run $0 again\n";

        exit;
    }

    say "running $dist from $appdir...";
}

sub create_local_copy {
    my $dist = shift;

    my $local_copy = File::HomeDir->my_dist_data($dist,{create=>1});

    print "copying $dist app files to $local_copy...\n";

    dircopy( dist_dir($dist) => $local_copy );

    return $local_copy;
}

1;

The module is short, but does a lotsa things. Namely:

  • if the script is called with the --install option, it’ll make a copy of the distribution share directory to the specified location (or the current directory, if no location was given) and assume that location to be the root directory of the app.

  • If the environment variable DANCER_APPDIR is defined, it assumes the user already know what she’s doing and will leave things as-is.

  • If not, it’ll check if the current directory has a config.yml, and if it does, take it as root directory.

  • If not, it will use the user-specific directory given by File::HomeDir::my_dist_data() as the root directory of the app, populating it on the fly if it doesn’t already exist.

  • After all that, if it finds a file called ‘REMOVE_ME’ in the app root dir, it’ll stop and require the user to delete it before allowing the application to start (which is meant to help for the cases where the app won’t work without configuration changes).

And, so far, there’s all there is to it. Strangely, it seems to work and take care of the most common cases. The code for Dancer::Local lives in the usual Github spot. But before I send it CPANward, I have to ask the question: am I unto something, or I am off my ever-elusive rockers?