File Coverage

blib/lib/App/Cme/Command/run.pm
Criterion Covered Total %
statement 158 191 82.7
branch 27 52 51.9
condition 19 29 65.5
subroutine 22 24 91.6
pod 5 9 55.5
total 231 305 75.7


line stmt bran cond sub pod time code
1             #
2             # This file is part of App-Cme
3             #
4             # This software is Copyright (c) 2014-2022 by Dominique Dumont <ddumont@cpan.org>.
5             #
6             # This is free software, licensed under:
7             #
8             # The GNU Lesser General Public License, Version 2.1, February 1999
9             #
10             # ABSTRACT: Run a cme script
11              
12             package App::Cme::Command::run ;
13             $App::Cme::Command::run::VERSION = '1.036';
14 2     2   1394 use strict;
  2         10  
  2         64  
15 2     2   10 use warnings;
  2         3  
  2         51  
16 2     2   29 use v5.20;
  2         8  
17 2     2   599 use File::HomeDir;
  2         6055  
  2         128  
18 2     2   910 use Path::Tiny;
  2         16453  
  2         91  
19 2     2   883 use Config::Model;
  2         503740  
  2         108  
20 2     2   16 use Log::Log4perl qw(get_logger :levels);
  2         4  
  2         27  
21              
22 2     2   346 use Encode qw(decode_utf8);
  2         4  
  2         114  
23              
24 2     2   583 use App::Cme -command ;
  2         6  
  2         19  
25              
26 2     2   18754 use base qw/App::Cme::Common/;
  2         5  
  2         632  
27 2     2   16 use feature qw/postderef signatures/;
  2         3  
  2         234  
28 2     2   13 no warnings qw/experimental::postderef experimental::signatures experimental::smartmatch/;
  2         5  
  2         5751  
29              
30              
31             my $__test_home = '';
32             # used only by tests
33             ## no critic (Subroutines::ProhibitUnusedPrivateSubroutines)
34 0     0   0 sub _set_test_home { $__test_home = shift; return;}
  0         0  
35              
36             my $home = $__test_home || File::HomeDir->my_home;
37              
38             my @script_paths = map {path($_)} (
39             "$home/.cme/scripts",
40             "/etc/cme/scripts/",
41             );
42              
43             push @script_paths, path($INC{"Config/Model.pm"})->parent->child("Model/scripts") ;
44              
45             sub opt_spec {
46 11     11 1 39 my ( $class, $app ) = @_;
47             return (
48 11         139 [ "arg=s@" => "script argument. run 'cme run <script> -doc' for possible arguments" ],
49             [ "backup:s" => "Create a backup of configuration files before saving." ],
50             [ "commit|c:s" => "commit change with passed message" ],
51             [ "cat" => "Show the script file" ],
52             [ "no-commit|nc!" => "skip commit to git" ],
53             [ "doc!" => "show documention of script" ],
54             [ "list!" => "list available scripts" ],
55             $class->cme_global_options,
56             );
57             }
58              
59             sub validate_args {
60 11     11 1 46609 my ($self, $opt, $args) = @_;
61              
62 11         87 $self->check_unknown_args($args);
63 11         27 return;
64             }
65              
66             sub usage_desc {
67 11     11 1 27507 my ($self) = @_;
68 11         66 my $desc = $self->SUPER::usage_desc; # "%c COMMAND %o"
69 11         284 return "$desc [ script ] [ -args foo=12 [ -args bar=13 ]";
70             }
71              
72             sub description {
73 0     0 1 0 my ($self) = @_;
74 0         0 return $self->get_documentation;
75             }
76              
77 11     11 0 29 sub check_script_arguments ($self, $opt, $script_name) {
  11         27  
  11         28  
  11         22  
  11         19  
78 11 50 33     144 if ($opt->{list} or not $script_name) {
79 0         0 my @scripts;
80 0         0 foreach my $path ( @script_paths ) {
81 0 0       0 next unless $path->is_dir;
82 0         0 push @scripts, grep { ! /~$/ } $path->children();
  0         0  
83             }
84 0 0       0 say $opt->{list} ? "Available scripts:" : "Missing script argument. Choose one of:";
85 0         0 say map {"- ".$_->basename."\n"} @scripts ;
  0         0  
86 0         0 return 0;
87             }
88 11         41 return 1;
89             }
90              
91 11     11 0 21 sub find_script_file ($self, $script_name) {
  11         27  
  11         22  
  11         26  
92 11         20 my $script;
93 11 50       66 if ($script_name =~ m!/!) {
94 11         73 $script = path($script_name);
95             }
96             else {
97             # check script in known locations
98 0         0 foreach my $path ( @script_paths ) {
99 0 0       0 next unless $path->is_dir;
100 0         0 $script = $path->child($script_name);
101 0 0       0 last if $script->is_file;
102             }
103             }
104              
105 11 50       678 die "Error: cannot find script $script_name\n" unless $script->is_file;
106              
107 11         672 return $script;
108             }
109              
110             # replace variables with command arguments or eval'ed variables or env variables
111             ## no critic (Subroutines::ProhibitManyArgs)
112 24     24 0 40 sub replace_var_in_value ($user_args, $script_var, $default, $missing, $vars) {
  24         39  
  24         33  
  24         32  
  24         37  
  24         36  
  24         37  
113 24         112 my $var_pattern = qr~(?<!\\) \$([a-zA-Z]\w+) (?!\s*{)~x;
114              
115 24         68 foreach ($vars->@*) {
116             # change $var but not \$var, not $var{} and not $1
117 24         278 s~ $var_pattern
118 13   100     173 ~ $user_args->{$1} // $script_var->{$1} // $ENV{$1} // $default->{$1} // '$'.$1 ~xeg;
      66        
      66        
      66        
119              
120             # register vars without replacements
121 24         165 foreach my $var (m~ $var_pattern ~xg) {
122 3         11 $missing->{$var} = 1 ;
123             }
124              
125             # now change \$var in $var
126 24         94 s!\\\$!\$!g;
127             }
128 24         69 return;
129             }
130              
131 11     11 0 179 sub parse_script ($script, $content, $user_args, $app_args) {
  11         23  
  11         21  
  11         19  
  11         19  
  11         23  
132 11         40 my %var;
133              
134             # find if all variables are accounted for
135             my %missing ;
136              
137             # provide default values
138 11         0 my %default ;
139              
140             # %args can be used in var section of a script. A new entry in
141             # added in %missing if the script tries to read an undefined value
142 11         189 tie my %args, 'App::Cme::Run::Var', \%missing, \%default;
143 11         188 %args = $user_args->%*;
144              
145 11         218 my @lines = split /\n/,$content;
146 11         47 my @load;
147             my @doc;
148 11         0 my $commit_msg ;
149 11         23 my $line_nb = 0;
150              
151             # check content, store app
152 11         42 while (@lines) {
153 32         77 my $line = shift @lines;
154 32         58 $line_nb++;
155 32         87 $line =~ s/#.*//; # remove comments
156 32         98 $line =~ s/^\s+//;
157 32         128 $line =~ s/\s+$//;
158 32         57 my ($key,@value);
159              
160 32 100       132 if ($line =~ /^---\s*(\w+)$/) {
    100          
161 2         11 $key = $1;
162 2         23 while ($lines[0] !~ /^---/) {
163 8         22 $lines[0] =~ s/#.*//; # remove comments
164 8         22 push @value, shift @lines;
165             }
166             }
167             elsif ($line eq '---') {
168 2         6 next;
169             }
170             else {
171 28         152 ($key,@value) = split /[\s:]+/, $line, 2;
172             }
173              
174 30 50       79 next unless $key ; # empty line
175              
176 30 100       194 replace_var_in_value($user_args, \%var, \%default, \%missing, \@value) unless $key eq 'var';
177              
178 30         61 for ($key) {
179 30         100 when (/^app/) {
180 11         76 unshift @$app_args, @value;
181             }
182 19         73 when ('var') {
183             # value comes from system file, not from user data
184 6         1002 my $res = eval ("@value") ; ## no critic (ProhibitStringyEval)
185 6 50       77 die "Error in var specification line $line_nb: $@\n" if $@;
186             }
187 13         34 when ('default') {
188             # multi-line default value is not supported
189 2         12 my ($dk, $dv) = split /[\s:=]+/, $value[0], 2;
190 2         12 $default{$dk} = $dv;
191             }
192 11         25 when ('doc') {
193 0         0 push @doc, @value;
194             }
195 11         28 when ('load') {
196 11         54 push @load, @value;
197             }
198 0         0 when ('commit') {
199 0         0 $commit_msg = join "\n",@value;
200             }
201 0         0 default {
202 0         0 die "Error in file $script line $line_nb: unexpected '$key' instruction\n";
203             }
204             }
205             }
206             return {
207 11         137 doc => \@doc,
208             commit_msg => $commit_msg,
209             missing => \%missing,
210             load => \@load,
211             }
212             }
213              
214             sub execute {
215 11     11 1 86 my ($self, $opt, $app_args) = @_;
216              
217             # cannot use logger until Config::Model is initialised
218              
219             # see Debian #839593 and perlunicook(1) section X 13
220 11         27 @$app_args = map { decode_utf8($_, 1) } @$app_args;
  12         284  
221              
222 11         163 my $script_name = shift @$app_args;
223              
224 11 50       57 return unless $self->check_script_arguments($opt, $script_name);
225              
226 11         45 my $script = $self->find_script_file($script_name);
227              
228 11         236 my $content = $script->slurp_utf8;
229              
230 11 50       3325 if ($opt->{cat}) {
231 0         0 print $content;
232 0         0 return;
233             }
234              
235             # parse variables passed on command line
236 11         34 my %user_args = map { split '=',$_,2; } @{ $opt->{arg} };
  4         35  
  11         56  
237              
238 11 100 66     121 if ($content =~ m/^#!/ or $content =~ /^use/m) {
239 1         5 splice @ARGV, 0,2; # remove 'run script' arguments
240 1     1   425 my $done = eval $script->slurp_utf8."\n1;\n"; ## no critic (BuiltinFunctions::ProhibitStringyEval)
  1         2  
  1         163  
  1         5  
241 1 50       18 die "Error in script $script_name: $@\n" unless $done;
242 1         84 return;
243             }
244              
245 10         51 my $script_data = parse_script($script, $content, \%user_args, $app_args);
246 10         29 my $commit_msg = $script_data->{commit_msg};
247              
248 10 50       44 if ($opt->doc) {
249 0         0 say join "\n", $script_data->{doc}->@*;
250 0 0       0 say "will commit with message: '$commit_msg'" if $commit_msg;
251 0         0 return;
252             }
253              
254 10 100       113 if (my @missing = sort keys $script_data->{missing}->%*) {
255             die "Error: Missing variables '". join("', '",@missing)."' in command arguments for script $script\n"
256 3         28 ."Please use option '".join(' ', map { "-arg $_=xxx"} @missing)."'\n";
  5         174  
257             }
258              
259 7         51 $self->process_args($opt, $app_args);
260              
261             # override commit message. may also trigger a commit even if none
262             # is specified in script
263 7 50       30 if ($opt->{commit}) {
264 0         0 $commit_msg = $opt->{commit};
265             }
266              
267             # check if workspace and index are clean
268 7 50 33     29 if ($commit_msg and not $opt->{no_commit}) {
269             ## no critic(InputOutput::ProhibitBacktickOperators)
270 0         0 my $r = `git status --porcelain --untracked-files=no`;
271 0 0       0 die "Cannot run commit command in a non clean repo. Please commit or stash pending changes: $r\n"
272             if $r;
273             }
274              
275 7 100       32 $opt->{_verbose} = 'Loader' if $opt->{verbose};
276              
277             # call loads
278 7         56 my ($model, $inst, $root) = $self->init_cme($opt,$app_args);
279 7         50215 map { $root->load($_) } $script_data->{load}->@*;
  7         56  
280              
281 7 50       66624 unless ($inst->needs_save) {
282 0         0 say "No change were applied";
283 0         0 return;
284             }
285              
286 7         201 $self->save($inst,$opt) ;
287              
288             # commit if needed
289 7 50 33     48 if ($commit_msg and not $opt->{no_commit}) {
290 0         0 system(qw/git commit -a -m/, $commit_msg);
291             }
292              
293 7         486 return;
294             }
295              
296             package App::Cme::Run::Var; ## no critic (Modules::ProhibitMultiplePackages)
297             $App::Cme::Run::Var::VERSION = '1.036';
298             require Tie::Hash;
299              
300             ## no critic (ClassHierarchies::ProhibitExplicitISA)
301             our @ISA = qw(Tie::ExtraHash);
302              
303             sub FETCH {
304 6     6   22 my ($self, $key) = @_ ;
305 6         22 my ($h, $missing, $default) = @$self;
306 6   100     35 my $res = $h->{$key} // $default->{$key} ;
307 6 100       26 $missing->{$key} = 1 unless defined $res;
308 6   100     82 return $res // '';
309             }
310              
311             1;
312              
313             __END__
314              
315             =pod
316              
317             =encoding UTF-8
318              
319             =head1 NAME
320              
321             App::Cme::Command::run - Run a cme script
322              
323             =head1 VERSION
324              
325             version 1.036
326              
327             =head1 SYNOPSIS
328              
329             $ cat ~/.cme/scripts/remove-mia
330             doc: remove mia from Uploaders. Require mia parameter
331             # declare app to configure
332             app: dpkg
333             # specify one or more instructions
334             load: ! control source Uploaders:-~/$mia$/
335             # commit the modifications with a message (git only)
336             commit: remove MIA dev $mia
337              
338             $ cme run remove-mia -arg mia=longgone@d3bian.org
339              
340             # cme run can also use environment variables
341             $ cat ~/.cme/scripts/add-me-to-uploaders
342             app: dpkg-control
343             load: source Uploaders:.push("$DEBFULLNAME <$DEBEMAIL>")
344              
345             $ cme run add-me-to-uploaders
346             Reading package lists... Done
347             Building dependency tree
348             Reading state information... Done
349             Changes applied to dpkg-control configuration:
350             - source Uploaders:3: '<undef>' -> 'Dominique Dumont <dod@debian.org>'
351              
352             # show the script documentation
353             $ cme run remove-mia -doc
354             remove mia from Uploaders. require mia parameter
355              
356             # list scripts
357             $ cme run -list
358             Available scripts:
359             - update-copyright
360             - add-me-to-uploaders
361              
362             =head1 DESCRIPTION
363              
364             Run a script written with cme DSL (Design specific language) or in
365             plain Perl.
366              
367             A script passed by name is searched in C<~/.cme/scripts>,
368             C</etc/cme/scripts> or C</usr/share/perl5/Config/Model/scripts>.
369             E.g. with C<cme run foo>, C<cme> loads either C<~/.cme/scripts/foo>,
370             C</etc/cme/scripts/foo> or
371             C</usr/share/perl5/Config/Model/scripts/foo>
372              
373             No search is done if the script is passed with a path
374             (e.g. C<cme run ./foo>)
375              
376             C<cme run> can also run plain Perl script. This is syntactic sugar to
377             avoid polluting global namespace, i.e. there's no need to store a
378             script using L<cme function|Config::Model/cme> in C</usr/local/bin/>.
379              
380             When run, this script:
381              
382             =over
383              
384             =item *
385              
386             opens the configuration file of C<app>
387              
388             =item *
389              
390             applies the modifications specified with C<load> instructions
391              
392             =item *
393              
394             save the configuration files
395              
396             =item *
397              
398             commits the result if C<commit> is specified (either in script or on command line).
399              
400             =back
401              
402             See L<App::Cme::Command::run> for details.
403              
404             =head1 Syntax
405              
406             The script accepts instructions in the form:
407              
408             key: value
409              
410             The key line can be repeated when needed.
411              
412             Multi line values can also be:
413              
414             --- key
415             multi line value
416             ---
417              
418             The script accepts the following instructions:
419              
420             =over
421              
422             =item app
423              
424             Specify the target application. Must be one of the application listed
425             by C<cme list> command. Mandatory. Only one C<app> instruction is
426             allowed.
427              
428             =item var
429              
430             Use Perl code to specify variables usable in this script. The Perl
431             code must store data in C<%var> hash. For instance:
432              
433             var: my @l = localtime; $var{year} = $l[5]+1900;
434              
435             The hash C<%args> contains the variables passed with the C<-arg>
436             option. Reading a value from C<%args> which is not set by user
437             triggers a missing option error. Use C<exists> if you need to test if
438             a argument was set by user:
439              
440             var: $var{foo} = exists $var{bar} ? $var{bar} : 'default' # good
441             var: $var{foo} = $var{bar} || 'default' # triggers a "missing arg" error
442              
443             =item load
444              
445             Specify the modifications to apply using a string as specified in
446             L<Config::Model::Loader>. This string can contain variable
447             (e.g. C<$foo>) which are replaced by command argument (e.g. C<-arg
448             foo=bar>) or by a variable set in var: line (e.g. C<$var{foo}> as set
449             above) or by an environment variable (e.g. C<$ENV{foo}>)
450              
451             =item commit
452              
453             Specify that the change must be committed with the passed commit
454             message. When this option is used, C<cme> raises an error if used on a
455             non-clean workspace. This option works only with L<git>.
456              
457             =back
458              
459             All instructions can use variables like C<$stuff> whose value can be
460             specified with C<-arg> options, with a Perl variable (from C<var:>
461             section explained above) or with an environment variable:
462              
463             For instance:
464              
465             cme run -arg var1=foo -arg var2=bar
466              
467             transforms the instruction:
468              
469             load: ! a=$var1 b=$var2
470              
471             in
472              
473             load: ! a=foo b=bar
474              
475             =head1 Options
476              
477             =head2 list
478              
479             List available scripts and exits.
480              
481             =head2 arg
482              
483             Arguments for the cme scripts which are used to substitute variables.
484              
485             =head2 doc
486              
487             Show the script documentation. (Note that C<--help> options show the
488             documentation of C<cme run> command)
489              
490             =head2 cat
491              
492             Pop the hood and show the content of the script.
493              
494             =head2 commit
495              
496             Like the commit instruction in script. Specify that the change must be
497             committed with the passed commit message.
498              
499             =head2 no-commit
500              
501             Don't commit to git (even if the above option is set)
502              
503             =head2 verbose
504              
505             Show effect of the modify instructions.
506              
507             =head1 Common options
508              
509             See L<cme/"Global Options">.
510              
511             =head1 Examples
512              
513             =head2 update copyright years in C<debian/copyright>
514              
515             $ cme run update-copyright -cat
516             app: dpkg-copyright
517             load: Files:~ Copyright=~"s/2016,?\s+$name/2017, $name/g"
518             commit: updated copyright year of $name
519              
520             $ cme run update-copyright -arg "name=Dominique Dumont"
521             cme: using Dpkg::Copyright model
522             Changes applied to dpkg-copyright configuration:
523             - Files:"*" Copyright: '2005-2016, Dominique Dumont <dod@debian.org>' -> '2005-2017, Dominique Dumont <dod@debian.org>'
524             - Files:"lib/Dpkg/Copyright/Scanner.pm" Copyright:
525             @@ -1,2 +1,2 @@
526             -2014-2016, Dominique Dumont <dod@debian.org>
527             +2014-2017, Dominique Dumont <dod@debian.org>
528             2005-2012, Jonas Smedegaard <dr@jones.dk>
529              
530             [master ac2e6410] updated copyright year of Dominique Dumont
531             1 file changed, 2 insertions(+), 2 deletions(-)
532              
533             =head2 update VcsGit in debian/control
534              
535             $ cme run set-vcs-git -cat
536             doc: update control Vcs-Browser and Vcs-git from git remote value
537             doc: parameters: remote (default is origin)
538             doc:
539             doc: example:
540             doc: cme run set-vcs-git
541             doc: cme run set-vcs-git -arg remote=debian
542            
543             app: dpkg-control
544             default: remote: origin
545            
546             var: chomp ( $var{url} = `git remote get-url $args{remote}` ) ;
547             var: $var{url} =~ s!^git@!https://!;
548             var: $var{url} =~ s!(https?://[\w.]+):!$1/!;
549             var: $var{browser} = $var{url};
550             var: $var{browser} =~ s/.git$//;
551            
552             load: ! source Vcs-Browser="$browser" Vcs-Git="$url"
553             commit: control: update Vcs-Browser and Vcs-Git
554              
555             This script can also be written using multi line instructions:
556              
557             $ cme run set-vcs-git -cat
558             --- doc
559             update control Vcs-Browser and Vcs-git from git remote value
560             parameters: remote (default is origin)
561            
562             example:
563             cme run set-vcs-git
564             cme run set-vcs-git -arg remote=debian
565             ---
566            
567             app: dpkg-control
568             default: remote: origin
569            
570             --- var
571             chomp ( $var{url} = `git remote get-url $args{remote}` ) ;
572             $var{url} =~ s!^git@!https://!;
573             $var{url} =~ s!(https?://[\w.]+):!$1/!;
574             $var{browser} = $var{url};
575             $var{browser} =~ s/.git$//;
576             ---
577            
578             load: ! source Vcs-Browser="$browser" Vcs-Git="$url"
579             commit: control: update Vcs-Browser and Vcs-Git
580              
581             =head1 SEE ALSO
582              
583             L<cme>
584              
585             =head1 AUTHOR
586              
587             Dominique Dumont
588              
589             =head1 COPYRIGHT AND LICENSE
590              
591             This software is Copyright (c) 2014-2022 by Dominique Dumont <ddumont@cpan.org>.
592              
593             This is free software, licensed under:
594              
595             The GNU Lesser General Public License, Version 2.1, February 1999
596              
597             =cut