File Coverage

blib/lib/Mojolicious/Command/deploy/heroku.pm
Criterion Covered Total %
statement 1 3 33.3
branch n/a
condition n/a
subroutine 1 1 100.0
pod n/a
total 2 4 50.0


line stmt bran cond sub pod time code
1             package Mojolicious::Command::deploy::heroku;
2 1     1   27808 use Mojo::Base 'Mojolicious::Command';
  0            
  0            
3              
4             #use IO::All 'io';
5             use File::Path 'make_path';
6             use File::Slurp qw/ slurp write_file /;
7             use File::Spec;
8             use Getopt::Long qw/ GetOptions :config no_auto_abbrev no_ignore_case /;
9             use IPC::Cmd 'can_run';
10             use Mojo::IOLoop;
11             use Mojo::UserAgent;
12             use Mojolicious::Command::generate::heroku;
13             use Mojolicious::Command::generate::makefile;
14             use Net::Heroku;
15              
16             our $VERSION = 0.11;
17              
18             has tmpdir => sub { $ENV{MOJO_TMPDIR} || File::Spec->tmpdir };
19             has ua => sub { Mojo::UserAgent->new->ioloop(Mojo::IOLoop->singleton) };
20             has description => "Deploy Mojolicious app to Heroku.\n";
21             has opt => sub { {} };
22             has credentials_file => sub {"$ENV{HOME}/.heroku/credentials"};
23             has makefile => 'Makefile.PL';
24             has usage => <<"EOF";
25              
26             usage: $0 deploy heroku [OPTIONS]
27              
28             # Create new app with randomly selected name and deploy
29             $0 deploy heroku -c
30              
31             # Deploy to specified app and deploy (creates app if it does not exist)
32             $0 deploy heroku -n friggin-ponycorns
33              
34             These options are available:
35             -n, --appname Specify app name for deployment
36             -a, --api-key Heroku API key (read from ~/.heroku/credentials by default).
37             -c, --create Create app with randomly selected name
38             -v, --verbose Verbose output (heroku response, git output)
39             -h, --help This message
40             EOF
41              
42             sub opt_spec {
43             my $self = shift;
44             my $opt = {};
45              
46             return $opt
47             if GetOptions(
48             "appname|n=s" => sub { $opt->{name} = pop },
49             "api-key|a=s" => sub { $opt->{api_key} = pop },
50             "create|c" => sub { $opt->{create} = pop },
51             );
52             }
53              
54             sub validate {
55             my $self = shift;
56             my $opt = shift;
57              
58             my @errors =
59             map $_ . ' command not found' =>
60             grep !can_run($_) => qw/ git ssh ssh-keygen /;
61              
62             # Create or appname
63             push @errors => '--create or --appname must be specified'
64             if !defined $opt->{create} and !defined $opt->{name};
65              
66             return @errors;
67             }
68              
69             sub run {
70             my $self = shift;
71              
72             # App home dir
73             $self->ua->server->app($self->app);
74             my $home_dir = $self->ua->server->app->home->to_string;
75              
76             # Command-line Options
77             my $opt = $self->opt_spec(@_);
78              
79             # Validate
80             my @errors = $self->validate($opt);
81             die "\n" . join("\n" => @errors) . "\n" . $self->usage if @errors;
82              
83             # Net::Heroku
84             my $h = $self->heroku_object($opt->{api_key} || $self->local_api_key);
85              
86             # Prepare
87             $self->generate_makefile;
88             $self->generate_herokufile;
89              
90             # SSH key permissions
91             if (!remote_key_match($h)) {
92             print "\nHeroku does not have any SSH keys stored for you.";
93             my ($file, $key) = create_or_get_key();
94              
95             print "\nUploading SSH public key $file\n";
96             $h->add_key(key => $key);
97             }
98              
99             # Create
100             my $res = verify_app(
101             $h,
102             config_app(
103             $h,
104             create_or_get_app($h, $opt),
105             {BUILDPACK_URL => 'http://github.com/tempire/perloku.git'}
106             )
107             );
108              
109             print "Collecting all files in "
110             . $self->app->home . " ..."
111             . " (Ctrl-C to cancel)\n";
112              
113             # Upload
114             push_repo(
115             fill_repo(
116             $self->create_repo($home_dir, $self->tmpdir),
117             $self->app->home->list_files
118             ),
119             $res
120             );
121             }
122              
123             sub prompt {
124             my ($message, @options) = @_;
125              
126             print "\n$message\n";
127              
128             for (my $i = 0; $i < @options; $i++) {
129             printf "\n%d) %s" => $i + 1, $options[$i];
130             }
131              
132             print "\n\n> ";
133              
134             my $response = ;
135             chomp $response;
136              
137             return ($response
138             && $response =~ /^\d+$/
139             && $response > 0
140             && $response < @options + 1)
141             ? $options[$response - 1]
142             : prompt($message, @options);
143             }
144              
145             sub choose_key {
146             return prompt
147             "Which of the following keys would you like to use with Heroku?",
148             ssh_keys();
149             }
150              
151             sub generate_key {
152             print "\nGenerating an SSH public key...\n";
153              
154             my $file = "id_rsa";
155              
156             # Get/create dir
157             #my $dir = io->dir("$ENV{HOME}/.ssh")->perms(0700)->mkdir;
158             my $dir = File::Spec->catfile($ENV{HOME}, '.ssh');
159             make_path($dir, {mode => 0700});
160              
161             # Generate RSA key
162             my $path = File::Spec->catfile($dir, $file);
163             `ssh-keygen -t rsa -N "" -f "$path" 2>&1`;
164              
165             return "$path.pub";
166             }
167              
168             sub ssh_keys {
169              
170             #return grep /\.pub$/ => io->dir("$ENV{HOME}/.ssh/")->all;
171             opendir(my $dir => File::Spec->catfile($ENV{HOME}, '.ssh')) or return;
172             return
173             map File::Spec->catfile($ENV{HOME}, '.ssh', $_) =>
174             grep /\.pub$/ => readdir($dir);
175             }
176              
177              
178             sub create_or_get_key {
179              
180             #return io->file(ssh_keys() ? choose_key : generate_key)->slurp;
181             my $file = ssh_keys() ? choose_key : generate_key;
182             return $file, slurp $file;
183             }
184              
185             sub generate_makefile {
186             my $self = shift;
187              
188             my $command = Mojolicious::Command::generate::makefile->new;
189             my $file = $self->app->home->rel_file($self->makefile);
190              
191             if (!file_exists($file)) {
192             print "$file not found...generating\n";
193             return $command->run;
194             }
195             }
196              
197             sub generate_herokufile {
198             my $self = shift;
199              
200             my $command = Mojolicious::Command::generate::heroku->new;
201              
202             if (!file_exists($command->file)) {
203             print $command->file . " not found...generating\n";
204             return $command->run;
205             }
206             }
207              
208             sub file_exists {
209              
210             #return io(shift)->exists;
211             return -e shift;
212             }
213              
214             sub heroku_object {
215             my ($self, $api_key) = @_;
216              
217             my $h;
218              
219             if (defined $api_key) {
220             $h = Net::Heroku->new(api_key => $api_key);
221             }
222             else {
223             my @credentials;
224              
225             while (!$h || $h->error) {
226             @credentials = prompt_user_pass();
227             $h = Net::Heroku->new(@credentials);
228             }
229              
230             $self->save_local_api_key($credentials[1], $h->ua->api_key);
231             }
232              
233             return $h;
234             }
235              
236             sub save_local_api_key {
237             my ($self, $email, $api_key) = @_;
238              
239             #my $dir = io->dir("$ENV{HOME}/.heroku")->perms(0700)->mkdir;
240             my $dir = "$ENV{HOME}/.heroku";
241             make_path($dir, {mode => 0700});
242              
243             #return io("$dir/credentials")->print($email, "\n", $api_key, "\n");
244             return write_file "$dir/credentials", $email, "\n", $api_key, "\n";
245             }
246              
247             sub local_api_key {
248             my $self = shift;
249              
250             return if !-T $self->credentials_file;
251              
252             #my $api_key = +(io->file($self->credentials_file)->slurp)[-1];
253             my $api_key = +(slurp $self->credentials_file)[-1];
254             chomp $api_key;
255              
256             return $api_key;
257             }
258              
259             sub prompt_user_pass {
260             print "\nPlease enter your Heroku credentials";
261             print "\n (Sign up for free at https://api.heroku.com/signup)";
262              
263             print "\n\nEmail: ";
264             my $email = ;
265             chomp $email;
266              
267             print "Password: ";
268             my $password = ;
269             chomp $password;
270              
271             return (email => $email, password => $password);
272             }
273              
274             sub create_repo {
275             my ($self, $home_dir, $tmp_dir) = @_;
276              
277             my $git_dir =
278             File::Spec->catfile($tmp_dir, 'mojo_deploy_git', int rand 1000);
279             make_path($git_dir);
280              
281             my $r = {
282             work_tree => $home_dir,
283             git_dir => $git_dir,
284             };
285              
286             git($r, 'init');
287              
288             return $r;
289             }
290              
291             sub fill_repo {
292             my ($r, $all_files) = @_;
293              
294             # .gitignore'd files
295             my @ignore =
296             git($r, 'ls-files' => '--others' => '-i' => '--exclude-standard');
297              
298             my @files =
299             grep { my $file = $_; $file if !grep $file =~ /$_\W*/ => @ignore }
300             @$all_files;
301              
302             # Add files filtered by .gitignore
303             print "Adding file $_\n" for @files;
304             git($r, add => @files);
305              
306             git($r, commit => '-m' => '"Initial commit"');
307             print int(@files) . " files added\n\n";
308              
309             return $r;
310             }
311              
312             sub push_repo {
313             my ($r, $res) = @_;
314              
315             git($r, remote => add => heroku => $res->{git_url});
316             git($r, push => '--force' => heroku => 'master');
317              
318             return $r;
319             }
320              
321             sub git {
322             my $r = shift;
323             my $cmd =
324             "git -c core.autocrlf=false --work-tree=\"$r->{work_tree}\" --git-dir=\"$r->{git_dir}\" "
325             . join " " => @_;
326             return `$cmd`;
327             }
328              
329             sub create_or_get_app {
330             my ($h, $opt) = @_;
331              
332             # Attempt create
333             my %params = defined $opt->{name} ? (name => $opt->{name}) : ();
334             my $res = {$h->create(%params)};
335             my $error = $h->error;
336              
337             # Attempt retrieval
338             $res = shift @{[grep $_->{name} eq $opt->{name} => $h->apps]}
339             if $h->error and $h->error eq 'Name is already taken';
340              
341             print "Upload failed for $opt->{name}: " . $error . "\n" and exit if !$res;
342              
343             return $res;
344             }
345              
346             sub remote_key_match {
347             my $h = pop;
348              
349             my %remote_keys = map { $_->{contents} => $_->{email} } $h->keys;
350             my @local_keys = map substr(slurp($_), 0, -1) => ssh_keys();
351              
352             #my @local_keys = map substr($_->all, 0, -1) => ssh_keys();
353              
354             return grep defined $remote_keys{$_} => @local_keys;
355             }
356              
357             sub config_app {
358             my ($h, $res, $config) = @_;
359              
360             print "Configuration failed for app $res->{name}: " . $h->error . "\n"
361             and exit
362             if !$h->add_config(name => $res->{name}, %$config);
363              
364             return $res;
365             }
366              
367             sub verify_app {
368             my ($h, $res) = @_;
369              
370             # This is the way Heroku's official command-line client does it.
371              
372             for (0 .. 5) {
373             last if $h->app_created(name => $res->{name});
374             sleep 1;
375             print ' . ';
376             }
377              
378             return $res;
379             }
380              
381             1;
382              
383             =head1 NAME
384              
385             Mojolicious::Command::deploy::heroku - Deploy to Heroku
386              
387             =head1 USAGE
388              
389             script/my_app deploy heroku [OPTIONS]
390              
391             # Create new app with randomly selected name and deploy
392             script/my_app deploy heroku --create
393              
394             # Create new app with randomly selected name and specified api key
395             script/my_app deploy heroku --create --api-key 123412341234...
396              
397             # Deploy app (new or existing) with specified name
398             script/my_app deploy heroku --name happy-cloud-1234
399              
400             These options are available:
401             -n, --appname Specify app for deployment
402             -a, --api-key Heroku API key (read from ~/.heroku/credentials by default).
403             -c, --create Create a new Heroku app
404             -v, --verbose Verbose output (heroku response, git output)
405             -h, --help This message
406              
407             =head1 DESCRIPTION
408              
409             L deploys a Mojolicious app to Heroku.
410              
411             *NOTE* The deploy command itself works on Windows, but the Heroku service does not reliably accept deployments from Windows. Your mileage may vary.
412              
413             *NOTE* This release works with Mojolicious versions 4.50 and above. For older Mojolicious versions, please use 0.10 or before.
414              
415             =head1 WORKFLOW
416              
417             =over 4
418              
419             =item 1) B
420              
421             L
422              
423             =item 2) B
424              
425             mojo generate lite_app hello
426              
427             =item 3) B
428              
429             hello deploy heroku --create
430              
431             The deploy command creates a git repository of the B in /tmp, and then pushes it to a remote heroku repository.
432              
433             =back
434              
435             =head1 SEE ALSO
436              
437             L,
438             L,
439             L,
440             L
441              
442             =head1 SOURCE
443              
444             L
445              
446             =head1 VERSION
447              
448             0.11
449              
450             =head1 AUTHOR
451              
452             Glen Hinkle C
453