Repository navigation
Expand file tree
/
Copy pathinstall.php
More file actions
executable file
·2984 lines (2583 loc) · 119 KB
/
Copy pathinstall.php
File metadata and controls
executable file
·2984 lines (2583 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env php
<?php
/**
* @file
* Install the template as a new Drupal extension, based on user input.
*
* It runs once per fresh copy of the template and deletes itself unless the
* 'Remove this script' prompt is answered 'no'. In an empty directory, it
* downloads the template of its own version first and runs the installer of
* that template.
*
* Environment variables:
* - SCRIPT_RUN_SKIP: Set to '1' to skip running of the script. Useful when
* unit-testing or requiring this file from other files.
* - INSTALL_*: Set environment variables to pre-fill prompts
* (e.g. INSTALL_NAME, INSTALL_TYPE, INSTALL_DRUPAL_VERSION).
* - INSTALL_BRANCHES: The branches the shipped workflows run on,
* comma-separated. Defaults to the 'push' branches of the committed
* '.github/workflows/test.yml', or '1.x'.
* - INSTALL_GITHUB_REPOSITORY: The GitHub repository the README badges name,
* as 'owner/name'. Defaults to the GitHub 'origin' remote of the git
* repository that the current directory is the root of, or to the machine
* name for both parts.
* - INSTALL_TEMPLATE_ARCHIVE: The '.tar.gz' archive that an installer in an
* empty directory downloads, as a URL or a path. Defaults to the GitHub
* archive of the version in 'TEMPLATE_VERSION'.
*
* Usage:
* @code
* php install.php
* php install.php --help
* php install.php --version
* @endcode
*/
declare(strict_types=1);
/**
* The template version, as 'git describe' output.
*
* '.gitattributes' marks this file 'export-subst', so 'git archive' expands
* the placeholder to the tag of the archived commit, or to a commit after a
* tag, such as '5.0.0-3-g1a2b3c4'. A copy that no archive produced keeps the
* placeholder. Git expands every placeholder up to the next dollar sign, so
* no other text in this file may start one.
*/
const TEMPLATE_VERSION = '$Format:%(describe:tags=true)$';
// phpcs:disable
// @embed-start
/**
* 🧙Prompty - Zero-dependency interactive CLI prompt library.
*
* Copy the contents of this file directly into your script, preserving
* this header.
*
* @license MIT
* @see LICENSE file for full license text.
*
* Copyright (c) 2026 Alex Skrypnyk (alex@drevops.com)
* https://github.com/AlexSkrypnyk/prompty
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software to deal in it without restriction, including the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies, subject to the following condition:
*
* This notice must be included in all copies of this file, including when
* used as a part of other files.
*
* @phpstan-type PromptyConfig array{
* symbols_unicode: array<string, string>,
* symbols_ascii: array<string, string>,
* colors: array<string, string>,
* spacing: array<string, string>,
* labels: array<string, string>,
* unicode: bool|null,
* ansi: bool|null,
* env_prefix: string,
* truthy: list<string>,
* falsy: list<string>,
* }
*/
// @phpstan-ignore-next-line
class Prompty{protected const ESCAPE_TIMEOUT=50000;protected static?self $_p0=NULL;protected static bool $_p1=FALSE;protected?string $_p2=NULL;protected array $_p3=[];protected array $_p4=[];protected?array $_p5=NULL;protected $_p6;protected array $_p7=['bar'=>'│','completed'=>'◆','active'=>'◇','intro'=>'┌','outro'=>'└','pointer'=>'❯','radio_on'=>'●','radio_off'=>'○','check_on'=>'◼','check_off'=>'◻','hint_arrow'=>'↳',];protected array $_p8=['bar'=>'|','completed'=>'+','active'=>'o','intro'=>'#','outro'=>'#','pointer'=>'>','radio_on'=>'(*)','radio_off'=>'( )','check_on'=>'[x]','check_off'=>'[ ]','hint_arrow'=>'-->',];protected array $_p9;protected array $_p10=['reset'=>"\033[0m",'dim'=>"\033[2m",'dim_italic'=>"\033[2;3m",'cyan'=>"\033[36m",'green'=>"\033[32m",'red'=>"\033[31m",'gray'=>"\033[90m",'bold'=>"\033[1m",'white'=>"\033[37m",];protected array $_p11;protected array $_p12=['indent'=>' ','hint_indent'=>' ','hint_cont'=>' ',];protected array $_p13=['yes'=>'Yes','no'=>'No','cancelled'=>'(cancelled)','none'=>'None','separator'=>'/',];protected?bool $_p14=NULL;protected?bool $_p15=NULL;protected string $_p16='PROMPTY_';protected array $_p17=['1','true','yes'];protected array $_p18=['0','false','no'];protected function __construct(){if($this->_p14===NULL){$a=getenv('LANG')?:getenv('LC_ALL')?:getenv('LC_CTYPE')?:setlocale(LC_CTYPE,'0')?:'';$this->_p14=stripos($a,'utf')!==FALSE;}$this->_p11=$this->_p10;if($this->_p15===NULL){$b=getenv('NO_COLOR');$this->_p15=($b!==FALSE&&$b!=='')||getenv('TERM')==='dumb'?FALSE:TRUE;}$this->_m1();}protected static function _m0():static{if(!static::$_p0 instanceof Prompty){static::$_p0=new static();}return static::$_p0;}protected function _m1():void{$this->_p9=$this->_p14?$this->_p7:$this->_p8;$this->_p10=$this->_p15?$this->_p11:array_fill_keys(array_keys($this->_p11),'');}protected function _m2():array{return['symbols_unicode'=>$this->_p7,'symbols_ascii'=>$this->_p8,'colors'=>$this->_p11,'spacing'=>$this->_p12,'labels'=>$this->_p13,'unicode'=>$this->_p14,'ansi'=>$this->_p15,'env_prefix'=>$this->_p16,'truthy'=>$this->_p17,'falsy'=>$this->_p18,];}protected function _m3(array $c):void{$this->_p7=$c['symbols_unicode'];$this->_p8=$c['symbols_ascii'];$this->_p11=$c['colors'];$this->_p12=$c['spacing'];$this->_p13=$c['labels'];$this->_p14=$c['unicode'];$this->_p15=$c['ansi'];$this->_p16=$c['env_prefix'];$this->_p17=$c['truthy'];$this->_p18=$c['falsy'];$this->_m1();}public static function config():array{$d=static::_m0();return['symbols_unicode'=>$d->_p7,'symbols_ascii'=>$d->_p8,'symbols'=>$d->_p9,'colors'=>$d->_p10,'spacing'=>$d->_p12,'labels'=>$d->_p13,'unicode'=>$d->_p14,'ansi'=>$d->_p15,'env_prefix'=>$d->_p16,'truthy'=>$d->_p17,'falsy'=>$d->_p18,];}public static function results():array{return static::_m0()->_p3;}public static function version():string{return str_starts_with('1.0.0','__')?'development':'1.0.0';}public static function configure(?array $symbols_unicode=NULL,?array $symbols_ascii=NULL,?array $colors=NULL,?array $spacing=NULL,?array $labels=NULL,?bool $unicode=NULL,?bool $ansi=NULL,?string $env_prefix=NULL,?array $truthy=NULL,?array $falsy=NULL,):void{$d=static::_m0();if($symbols_unicode!==NULL){$d->_m24('symbols_unicode',$symbols_unicode,$d->_p7);}if($symbols_ascii!==NULL){$d->_m24('symbols_ascii',$symbols_ascii,$d->_p8);}if($colors!==NULL){$d->_m24('colors',$colors,$d->_p11);}if($spacing!==NULL){$d->_m24('spacing',$spacing,$d->_p12);}if($labels!==NULL){$d->_m24('labels',$labels,$d->_p13);}if($truthy!==NULL){$d->_m25('truthy',$truthy);}if($falsy!==NULL){$d->_m25('falsy',$falsy);}if($symbols_unicode!==NULL){$d->_p7=array_replace($d->_p7,$symbols_unicode);}if($symbols_ascii!==NULL){$d->_p8=array_replace($d->_p8,$symbols_ascii);}if($colors!==NULL){$d->_p11=array_replace($d->_p11,$colors);$d->_p10=array_replace($d->_p10,$colors);}if($spacing!==NULL){$d->_p12=array_replace($d->_p12,$spacing);}if($labels!==NULL){$d->_p13=array_replace($d->_p13,$labels);}if($unicode!==NULL){$d->_p14=$unicode;}if($ansi!==NULL){$d->_p15=$ansi;}if($env_prefix!==NULL){$d->_p16=$env_prefix;}if($truthy!==NULL){$d->_p17=$truthy;}if($falsy!==NULL){$d->_p18=$falsy;}$d->_m1();}public static function flow(callable $steps,string|callable|null $intro=NULL,string|callable|null $outro=NULL,string|callable|null $cancelled=NULL,bool $numbering=FALSE,?array $symbols_unicode=NULL,?array $symbols_ascii=NULL,?array $colors=NULL,?array $spacing=NULL,?array $labels=NULL,?bool $unicode=NULL,?bool $ansi=NULL,?string $env_prefix=NULL,?array $truthy=NULL,?array $falsy=NULL,):?array{$d=static::_m0();$c=NULL;if($symbols_unicode!==NULL||$symbols_ascii!==NULL||$colors!==NULL||$spacing!==NULL||$labels!==NULL||$unicode!==NULL||$ansi!==NULL||$env_prefix!==NULL||$truthy!==NULL||$falsy!==NULL){$c=$d->_m2();static::configure($symbols_unicode,$symbols_ascii,$colors,$spacing,$labels,$unicode,$ansi,$env_prefix,$truthy,$falsy);}$d->_p3=[];static::$_p1=TRUE;try{$steps=$steps();$d->_m29($steps);$options=['numbering'=>$numbering,];$d->_m16();if($d->_p2!==NULL){register_shutdown_function(function()use($d):void{$d->_m17();});}if($intro!==NULL){is_callable($intro)?$intro($d->_p3):$d->_m18($d->_m31($intro));}$e=$d->_m41($steps,0,$options,'');if($e===FALSE){if($cancelled!==NULL){is_callable($cancelled)?$cancelled($d->_p3):$d->_m18($d->_m32($cancelled));}return NULL;}if($outro!==NULL){is_callable($outro)?$outro($d->_p3):$d->_m18($d->_m32($outro));}return $d->_p3;}finally{$d->_m17();$d->_p5=NULL;static::$_p1=FALSE;if($c!==NULL){$d->_m3($c);}}}public static function text(string $label,string $default='',string $placeholder='',string $description='',mixed $discovered=NULL,?callable $condition=NULL,array $children=[],?array $ctx=NULL,):\Closure|array|string|null{if(static::$_p1&&$ctx===NULL){$f=fn(array $ctx):\Closure|array|string|null=>static::text($label,default:$default,placeholder:$placeholder,description:$description,discovered:$discovered,ctx:$ctx,);if($condition!==NULL||$children!==[]){return['__call'=>$f,'__children'=>$children,'__condition'=>$condition];}return $f;}$d=static::_m0();$ctx??=['depth'=>0,'is_last'=>FALSE,'open'=>[],];$g=!static::$_p1;if($g){$d->_m16();}$h=$ctx['depth']??0;$_p4=$ctx['open']??[];$label=$d->_m22($label,$ctx);$default=$d->_m5($default);$placeholder=$d->_m5($placeholder);$i=$discovered??$ctx['discovered']??NULL;if($i!==NULL){$j=trim($d->_m5((string)$i));if($j===''){$j=$default!==''?$default:$placeholder;}$_p4=$d->_m40($j,$_p4);$d->_m18($d->_m35($label,$j,$h,$_p4));if($g){$d->_m17();}return $j;}$k=function(string $l)use($d,$label,$placeholder,$description,$h,$_p4):array{$l=$d->_m5($l);$m=$d->_m4('█','cyan');$j=$l===''?$d->_m4($placeholder,'gray').$m:$d->_m4($l,'white').$m;if($h===0){$lines=[$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description):[$d->_m7()]);$lines[]=$d->_m7().$d->_p12['indent'].$j;$lines[]=$d->_m7();return $lines;}$n=$d->_m7().$d->_m20($h,$_p4);$o=$d->_m21($h,$_p4);$lines=[$n.$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description,$h,$_p4):[$d->_m7().$o]);$lines[]=$d->_m7().$o.$j;$lines[]=$d->_m7().$o;return $lines;};$l=$default;$p=$d->_m18($k($l));while(TRUE){$q=$d->_m9();if($d->_m12($q)){$d->_m19($p,$d->_m36($label,$l,$h,$_p4));if($g){$d->_m17();}return NULL;}if($q==='enter'){$l=$d->_m5($l);$j=$l!==''?$l:$placeholder;$_p4=$d->_m40($j,$_p4);$d->_m19($p,$d->_m35($label,$j,$h,$_p4));if($g){$d->_m17();}return $j;}if($q==='backspace'){$l=preg_replace('/.\z/su','',$l)??substr($l,0,-1);}elseif($q==='space'){$l.=' ';}elseif(strlen($q)===1&&ord($q)>=32){$l.=$q;}$p=$d->_m19($p,$k($l));}}public static function select(string $label,array $options=[],string $default='',string $description='',array $hints=[],mixed $discovered=NULL,?callable $condition=NULL,array $children=[],?array $ctx=NULL,):\Closure|array|string|null{if(static::$_p1&&$ctx===NULL){$f=fn(array $ctx):\Closure|array|string|null=>static::select($label,options:$options,default:$default,description:$description,hints:$hints,discovered:$discovered,ctx:$ctx,);if($condition!==NULL||$children!==[]){return['__call'=>$f,'__children'=>$children,'__condition'=>$condition];}return $f;}$d=static::_m0();$ctx??=['depth'=>0,'is_last'=>FALSE,'open'=>[],];$g=!static::$_p1;$d->_m26($label,$options);$r=$default===''?NULL:$d->_m27('Default',$label,$default,$options);$i=$discovered??$ctx['discovered']??NULL;$s=$i===NULL?NULL:$d->_m27('Discovered',$label,$i,$options);if($g){$d->_m16();}$h=$ctx['depth']??0;$_p4=$ctx['open']??[];$label=$d->_m22($label,$ctx);$t=$d->_m23($options);$u=array_map($d->_m5(...),array_values($options));$v=array_map(fn(string $q)=>$hints[$q]??'',$t);if($s!==NULL){$_p4=$d->_m40($s,$_p4);$d->_m18($d->_m35($label,$options[$s],$h,$_p4));if($g){$d->_m17();}return $s;}$k=function(int $w)use($d,$label,$u,$description,$v,$h,$_p4):array{if($h===0){$lines=[$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description):[$d->_m7()]);foreach($u as $x=>$y){$z=$x===$w;$aa=$d->_m4($d->_p9[$z?'radio_on':'radio_off'],$z?'green':'dim');$ab=$z?$y:$d->_m4($y,'dim');$lines[]=$d->_m7().$d->_m8($z).$aa.' '.$ab;if($z&&($v[$x]??'')!==''){$lines=array_merge($lines,$d->_m34($v[$x]));}}$lines[]=$d->_m7();return $lines;}$n=$d->_m7().$d->_m20($h,$_p4);$o=$d->_m21($h,$_p4);$lines=[$n.$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description,$h,$_p4):[$d->_m7().$o]);foreach($u as $x=>$y){$z=$x===$w;$aa=$d->_m4($d->_p9[$z?'radio_on':'radio_off'],$z?'green':'dim');$ab=$z?$y:$d->_m4($y,'dim');$lines[]=$d->_m7().$o.$d->_m8($z).$aa.' '.$ab;if($z&&($v[$x]??'')!==''){$lines=array_merge($lines,$d->_m34($v[$x],$h,$_p4));}}$lines[]=$d->_m7().$o;return $lines;};$w=0;if($r!==NULL){$w=(int)array_search($r,$t,TRUE);}$p=$d->_m18($k($w));while(TRUE){$q=$d->_m9();if($d->_m12($q)){$d->_m19($p,$d->_m36($label,$u[$w],$h,$_p4));if($g){$d->_m17();}return NULL;}if($q==='enter'){$_p4=$d->_m40($t[$w],$_p4);$d->_m19($p,$d->_m35($label,$u[$w],$h,$_p4));if($g){$d->_m17();}return $t[$w];}if($q==='up'||$q==='left'){$w=($w-1+count($u))%count($u);}elseif($q==='down'||$q==='right'){$w=($w+1)%count($u);}$p=$d->_m19($p,$k($w));}}public static function multiselect(string $label,array $options=[],array $default=[],string $description='',array $hints=[],mixed $discovered=NULL,?callable $condition=NULL,array $children=[],?array $ctx=NULL,):\Closure|array|null{if(static::$_p1&&$ctx===NULL){$f=fn(array $ctx):\Closure|array|null=>static::multiselect($label,options:$options,default:$default,description:$description,hints:$hints,discovered:$discovered,ctx:$ctx,);if($condition!==NULL||$children!==[]){return['__call'=>$f,'__children'=>$children,'__condition'=>$condition];}return $f;}$d=static::_m0();$ctx??=['depth'=>0,'is_last'=>FALSE,'open'=>[],];$g=!static::$_p1;$i=$discovered??$ctx['discovered']??NULL;if(is_scalar($i)){$i=explode(',',(string)$i);}$d->_m26($label,$options);$ac=$d->_m28('Default',$label,$default,$options);$ad=$i===NULL?NULL:$d->_m28('Discovered',$label,$i,$options);if($g){$d->_m16();}$h=$ctx['depth']??0;$_p4=$ctx['open']??[];$label=$d->_m22($label,$ctx);$t=$d->_m23($options);$u=array_map($d->_m5(...),array_values($options));$v=array_map(fn(string $q)=>$hints[$q]??'',$t);if($ad!==NULL){$j=$ad!==[]?implode(', ',array_map(fn(string $q):string=>$options[$q],$ad)):$d->_m6('none');$_p4=$d->_m40($ad,$_p4);$d->_m18($d->_m35($label,$j,$h,$_p4));if($g){$d->_m17();}return $ad;}$k=function(int $w,array $ae)use($d,$label,$u,$description,$v,$h,$_p4):array{if($h===0){$lines=[$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description):[$d->_m7()]);foreach($u as $x=>$y){$af=$ae[$x]??FALSE;$z=$x===$w;$ag=$d->_m4($d->_p9[$af?'check_on':'check_off'],$z||$af?'green':'dim');$ab=$z||$af?$y:$d->_m4($y,'dim');$lines[]=$d->_m7().$d->_m8($z).$ag.' '.$ab;if($z&&($v[$x]??'')!==''){$lines=array_merge($lines,$d->_m34($v[$x]));}}$lines[]=$d->_m7();return $lines;}$n=$d->_m7().$d->_m20($h,$_p4);$o=$d->_m21($h,$_p4);$lines=[$n.$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description,$h,$_p4):[$d->_m7().$o]);foreach($u as $x=>$y){$af=$ae[$x]??FALSE;$z=$x===$w;$ag=$d->_m4($d->_p9[$af?'check_on':'check_off'],$z||$af?'green':'dim');$ab=$z||$af?$y:$d->_m4($y,'dim');$lines[]=$d->_m7().$o.$d->_m8($z).$ag.' '.$ab;if($z&&($v[$x]??'')!==''){$lines=array_merge($lines,$d->_m34($v[$x],$h,$_p4));}}$lines[]=$d->_m7().$o;return $lines;};$w=0;$ae=array_map(fn(string $q):bool=>in_array($q,$ac,TRUE),$t);$p=$d->_m18($k($w,$ae));while(TRUE){$q=$d->_m9();if($d->_m12($q)){$d->_m19($p,$d->_m36($label,'',$h,$_p4));if($g){$d->_m17();}return NULL;}if($q==='enter'){$ah=[];$ai=[];foreach($u as $x=>$aj){if($ae[$x]){$ah[]=$t[$x];$ai[]=$aj;}}$_p4=$d->_m40($ah,$_p4);$d->_m19($p,$d->_m35($label,$ai!==[]?implode(', ',$ai):$d->_m6('none'),$h,$_p4));if($g){$d->_m17();}return $ah;}if($q==='space'){$ae[$w]=!$ae[$w];}elseif($q==='up'||$q==='left'){$w=($w-1+count($u))%count($u);}elseif($q==='down'||$q==='right'){$w=($w+1)%count($u);}$p=$d->_m19($p,$k($w,$ae));}}public static function confirm(string $label,bool $default=TRUE,string $description='',mixed $discovered=NULL,?callable $condition=NULL,array $children=[],?array $ctx=NULL,):\Closure|array|bool|null{if(static::$_p1&&$ctx===NULL){$f=fn(array $ctx):\Closure|array|bool|null=>static::confirm($label,default:$default,description:$description,discovered:$discovered,ctx:$ctx,);if($condition!==NULL||$children!==[]){return['__call'=>$f,'__children'=>$children,'__condition'=>$condition];}return $f;}$d=static::_m0();$ctx??=['depth'=>0,'is_last'=>FALSE,'open'=>[],];$g=!static::$_p1;$truthy=$ctx['truthy']??$d->_p17;$falsy=$ctx['falsy']??$d->_p18;$i=$discovered??$ctx['discovered']??NULL;$ak=$i===NULL?NULL:$d->_m30($label,$i,$truthy,$falsy);if($g){$d->_m16();}$h=$ctx['depth']??0;$_p4=$ctx['open']??[];$label=$d->_m22($label,$ctx);if($ak!==NULL){$_p4=$d->_m40($ak,$_p4);$d->_m18($d->_m35($label,$ak?$d->_m6('yes'):$d->_m6('no'),$h,$_p4));if($g){$d->_m17();}return $ak;}$k=function(bool $al)use($d,$label,$description,$h,$_p4):array{$am=$al?$d->_m4($d->_p9['radio_on'],'green').' '.$d->_m6('yes').' '.$d->_m4($d->_m6('separator'),'dim').' '.$d->_m4($d->_p9['radio_off'],'dim').' '.$d->_m4($d->_m6('no'),'dim'):$d->_m4($d->_p9['radio_off'],'dim').' '.$d->_m4($d->_m6('yes'),'dim').' '.$d->_m4($d->_m6('separator'),'dim').' '.$d->_m4($d->_p9['radio_on'],'green').' '.$d->_m6('no');if($h===0){$lines=[$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description):[$d->_m7()]);$lines[]=$d->_m7().$d->_p12['indent'].$am;$lines[]=$d->_m7();return $lines;}$n=$d->_m7().$d->_m20($h,$_p4);$o=$d->_m21($h,$_p4);$lines=[$n.$d->_m4($d->_p9['active'],'cyan').$d->_p12['indent'].$label];$lines=array_merge($lines,$description!==''?$d->_m33($description,$h,$_p4):[$d->_m7().$o]);$lines[]=$d->_m7().$o.$am;$lines[]=$d->_m7().$o;return $lines;};$an=$default;$p=$d->_m18($k($an));while(TRUE){$q=$d->_m9();if($d->_m12($q)){$d->_m19($p,$d->_m36($label,$an?$d->_m6('yes'):$d->_m6('no'),$h,$_p4));if($g){$d->_m17();}return NULL;}if($q==='enter'){$_p4=$d->_m40($an,$_p4);$d->_m19($p,$d->_m35($label,$an?$d->_m6('yes'):$d->_m6('no'),$h,$_p4));if($g){$d->_m17();}return $an;}if(in_array($q,['left','right','up','down','tab'],TRUE)){$an=!$an;}elseif($q==='y'||$q==='Y'){$an=TRUE;}elseif($q==='n'||$q==='N'){$an=FALSE;}$p=$d->_m19($p,$k($an));}}public static function intro(string $message):void{$d=static::_m0();$d->_m18($d->_m31($message));}public static function outro(string $message):void{$d=static::_m0();$d->_m18($d->_m32($message));}public static function output(array $lines):int{return static::_m0()->_m18($lines);}protected function _m4(string $ab,string $ao):string{return isset($this->_p10[$ao])?$this->_p10[$ao].$ab.$this->_p10['reset']:$ab;}protected function _m5(string $ab):string{$ab=preg_replace('/\033\[\??[0-9;]*[A-Za-z]/','',$ab)??$ab;$ab=preg_replace('/[\t\n\x0b\x0c\r]+/',' ',$ab)??$ab;$ab=preg_replace('/[\x00-\x1f\x7f]/','',$ab)??$ab;return preg_replace('/\p{Cc}/u','',$ab)??$ab;}protected function _m6(string $ap):string{return $this->_m5($this->_p13[$ap]??'');}protected function _m7():string{return $this->_m4($this->_p9['bar'],'gray');}protected function _m8(bool $w):string{return($w?$this->_m4($this->_p9['pointer'],'cyan'):' ').' ';}protected function _m9():string{$aq=$this->_p6??STDIN;$ar=fread($aq,1);if($ar===FALSE||$ar===''){return'eof';}return match($ar){"\x03"=>'ctrl-c',"\n","\r"=>'enter',"\x7f","\x08"=>'backspace',"\t"=>'tab',' '=>'space',"\x1b"=>$this->_m10($aq),default=>$ar,};}protected function _m10($aq):string{$as=$this->_m11($aq);if($as!=='['&&$as!=='O'){return'escape';}$at='';do{$au=$this->_m11($aq);if($au===''){return'escape';}$at.=$au;}while($as==='['&&ord($au)>=0x20&&ord($au)<=0x3F);return match($at){'A'=>'up','B'=>'down','C'=>'right','D'=>'left',default=>'unknown',};}protected function _m11($aq):string{$av=[$aq];$aw=NULL;$ax=NULL;if(!stream_get_meta_data($aq)['seekable']&&stream_select($av,$aw,$ax,0,static::ESCAPE_TIMEOUT)===0){return'';}return(string)fread($aq,1);}protected function _m12(string $q):bool{return in_array($q,['ctrl-c','escape','eof'],TRUE);}protected function _m13():void{echo"\033[?25h";}protected function _m14():void{echo"\033[?25l";}protected function _m15(string $ay):void{shell_exec('stty '.$ay.' 2>/dev/null');}protected function _m16():void{$this->_p2=$this->_p6===NULL?(shell_exec('stty -g 2>/dev/null')?:NULL):NULL;if($this->_p2!==NULL){$this->_p2=trim($this->_p2);shell_exec('stty -echo -icanon -isig min 1 time 0 2>/dev/null');$this->_m14();}}protected function _m17():void{if($this->_p2!==NULL){$this->_m15($this->_p2);$this->_m13();$this->_p2=NULL;}}protected function _m18(array $lines):int{echo implode(PHP_EOL,$lines).PHP_EOL;return count($lines);}protected function _m19(int $az,array $lines):int{if($az>0){echo"\033[{$az}A\r\033[J";}return $this->_m18($lines);}protected function _m20(int $h,array $_p4):string{$ba=' ';for($bb=1;$bb<$h;$bb++){$ba.=($_p4[$bb]??FALSE)?$this->_m4($this->_p9['bar'],'gray').' ':' ';}return $ba;}protected function _m21(int $h,array $_p4):string{$ba=' ';for($bb=1;$bb<=$h;$bb++){$ba.=($_p4[$bb]??FALSE)?$this->_m4($this->_p9['bar'],'gray').' ':' ';}return $ba;}protected function _m22(string $label,array $ctx):string{$label=$this->_m5($label);if(isset($ctx['number'])){$bc=$ctx['number'];return $label.' '.$this->_m4('('.$bc.')','dim');}return $label;}protected function _m23(array $options):array{return array_map(strval(...),array_keys($options));}protected function _m24(string $bd,array $be,array $bf):void{foreach(array_keys($be)as $q){if(!array_key_exists($q,$bf)){throw new \InvalidArgumentException(sprintf('Configuration key "%s" for "%s" is not valid. Available keys: %s.',$q,$bd,implode(', ',array_keys($bf))));}}}protected function _m25(string $bd,array $be):void{if($be===[]){throw new \InvalidArgumentException(sprintf('No values declared for "%s". Provide at least one value.',$bd));}foreach($be as $l){if(trim($l)===''){throw new \InvalidArgumentException(sprintf('Blank value declared for "%s". Every value must hold a non-space character.',$bd));}}}protected function _m26(string $label,array $options):void{if($options===[]){throw new \InvalidArgumentException(sprintf('No options declared for "%s". Provide at least one option.',$label));}}protected function _m27(string $bd,string $label,mixed $l,array $options):string{$q=is_scalar($l)?trim((string)$l):get_debug_type($l);if(!array_key_exists($q,$options)){$bg=$options===[]?'none':implode(', ',$this->_m23($options));throw new \InvalidArgumentException(sprintf('%s value "%s" for "%s" is not a valid option. Available options: %s.',$bd,$q,$label,$bg));}return $q;}protected function _m28(string $bd,string $label,mixed $l,array $options):array{$bh=is_array($l)?$l:[$l];$bi=[];foreach($bh as $bj){if(is_scalar($bj)&&trim((string)$bj)===''){continue;}$bi[]=$this->_m27($bd,$label,$bj,$options);}return array_values(array_filter($this->_m23($options),fn(string $q):bool=>in_array($q,$bi,TRUE)));}protected function _m29(array $steps):void{foreach($steps as $q=>$bk){if((string)$q===''){throw new \InvalidArgumentException('A step must have a key: it names the answer in the results and the variable the answer can come from.');}$ap=$this->_p16.strtoupper((string)$q);if(preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/',$ap)!==1){throw new \InvalidArgumentException(sprintf('Step "%s" is looked up as the environment variable "%s", which cannot be exported. A variable name may hold only letters, digits and underscores, and may not start with a digit.',$q,$ap));}$children=is_array($bk)?$bk['__children']??NULL:NULL;if(is_array($children)){$this->_m29($children);}}}protected function _m30(string $label,mixed $l,array $truthy,array $falsy):bool{if(is_bool($l)){return $l;}$j=is_scalar($l)?trim((string)$l):get_debug_type($l);$bl=fn(string $bm):string=>strtolower(trim($bm));$bn=$bl($j);if(in_array($bn,array_map($bl,$truthy),TRUE)){return TRUE;}if(in_array($bn,array_map($bl,$falsy),TRUE)){return FALSE;}$bo=implode(', ',array_merge($truthy,$falsy));throw new \InvalidArgumentException(sprintf('Discovered value "%s" for "%s" is not a valid answer. Accepted values: %s.',$j,$label,$bo));}protected function _m31(string $message):array{$message=$this->_m5($message);return['',$this->_m4($this->_p9['intro'],'gray').$this->_p12['indent'].$this->_m4($message,'bold'),$this->_m7(),];}protected function _m32(string $message):array{$message=$this->_m5($message);return[$this->_m7(),$this->_m4($this->_p9['outro'],'gray').$this->_p12['indent'].$this->_m4($message,'green'),'',];}protected function _m33(string $description,int $h=0,array $_p4=[]):array{$o=$h>0?$this->_m21($h,$_p4):$this->_p12['indent'];$lines=array_map(fn(string $bp):string=>$this->_m7().$o.$this->_m4($this->_m5($bp),'dim_italic'),explode("\n",$description),);$lines[]=$this->_m7().($h>0?$this->_m21($h,$_p4):'');return $lines;}protected function _m34(string $bq,int $h=0,array $_p4=[]):array{$o=$h>0?$this->_m21($h,$_p4):'';$br=array_map($this->_m5(...),explode("\n",$bq));return array_map(fn(string $bp,int $x):string=>$this->_m7().$o.($x===0?$this->_p12['hint_indent'].$this->_m4($this->_p9['hint_arrow'],'dim').' '.$this->_m4($bp,'dim_italic'):$this->_p12['hint_cont'].$this->_m4($bp,'dim_italic')),$br,array_keys($br),);}protected function _m35(string $label,string $l,int $h=0,array $_p4=[]):array{$label=$this->_m5($label);$l=$this->_m5($l);if($h===0){return[$this->_m4($this->_p9['completed'],'cyan').$this->_p12['indent'].$label,$this->_m7().$this->_p12['indent'].$this->_m4($l,'dim'),$this->_m7(),];}$n=$this->_m7().$this->_m20($h,$_p4);$o=$this->_m21($h,$_p4);return[$n.$this->_m4($this->_p9['completed'],'cyan').$this->_p12['indent'].$label,$this->_m7().$o.$this->_m4($l,'dim'),$this->_m7().$o,];}protected function _m36(string $label,string $l,int $h=0,array $_p4=[]):array{$label=$this->_m5($label);$l=$this->_m5($l);if($h===0){return[$this->_m4($this->_p9['active'],'red').$this->_p12['indent'].$label,$this->_m7().$this->_p12['indent'].$this->_m4($l,'dim').$this->_m4(' '.$this->_m6('cancelled'),'red'),$this->_m7(),];}$n=$this->_m7().$this->_m20($h,$_p4);$o=$this->_m21($h,$_p4);return[$n.$this->_m4($this->_p9['active'],'red').$this->_p12['indent'].$label,$this->_m7().$o.$this->_m4($l,'dim').$this->_m4(' '.$this->_m6('cancelled'),'red'),$this->_m7().$o,];}protected function _m37(mixed $bk):?callable{if(is_callable($bk)||!is_array($bk)){return NULL;}$condition=$bk['__condition']??NULL;return is_callable($condition)?$condition:NULL;}protected function _m38(array $steps,array $_p3):bool{foreach($steps as $bk){$condition=$this->_m37($bk);if($condition===NULL||$condition($_p3)){return TRUE;}}return FALSE;}protected function _m39(int $h,bool $bs):void{if($h<1){return;}if($bs){unset($this->_p4[$h]);return;}$this->_p4[$h]=TRUE;}protected function _m40(mixed $l,array $_p4):array{$_p5=$this->_p5;if($_p5===NULL){return $_p4;}$_p3=$this->_p3;$_p3[(string)$_p5['key']]=$l;$this->_m39($_p5['depth'],!$this->_m38($_p5['rest'],$_p3));return $this->_p4;}protected function _m41(array $steps,int $h,array $options,string $bt):bool{$bu=0;$x=0;foreach($steps as $q=>$bk){$x++;if(is_callable($bk)){$f=$bk;$children=[];}else{$f=$bk['__call'];$children=is_array($bk['__children']??NULL)?$bk['__children']:[];}$condition=$this->_m37($bk);if($condition!==NULL&&!$condition($this->_p3)){continue;}$bu++;$bv=array_slice($steps,$x);$this->_m39($h,$bv===[]);$this->_p5=['key'=>$q,'depth'=>$h,'rest'=>$bv];$bc=$bt!==''?$bt.'.'.$bu:(string)$bu;$bw=getenv($this->_p16.strtoupper((string)$q));$ctx=['depth'=>$h,'is_last'=>$bv===[],'open'=>$this->_p4,'results'=>$this->_p3,'number'=>($options['numbering']??FALSE)?$bc:NULL,'discovered'=>$bw!==FALSE?$bw:NULL,'truthy'=>$this->_p17,'falsy'=>$this->_p18,];$l=$f($ctx);if($l===NULL){$this->_p5=NULL;return FALSE;}$this->_p3[$q]=$l;$this->_m40($l,$this->_p4);$this->_p5=NULL;if($this->_m38($children,$this->_p3)){$bx=$h+1;$by=$this->_m7().$this->_m20($bx,$this->_p4).$this->_m7();$this->_m18([$by]);if(!$this->_m41($children,$bx,$options,$bc)){return FALSE;}}}return TRUE;}}
// @embed-end
// phpcs:enable
/**
* Main functionality.
*
* @param array<string> $argv
* Array of arguments.
*/
function main(array $argv): void {
if (array_intersect(['help', '--help', '-h', '-?'], $argv)) {
print_help();
return;
}
if (in_array('--version', $argv, TRUE)) {
print_version();
return;
}
$dir = (string) getcwd();
if (is_empty_directory($dir, __FILE__)) {
install_from_archive($dir, __FILE__, array_slice($argv, 1));
return;
}
// The prompts rewrite the template in place, so a directory without it gets
// nothing but the placeholder logo.
if (!is_file('your_extension.info.yml')) {
throw new \Exception('The current directory holds no copy of the template. Run the installer in a copy of the template, or in an empty directory to download the template there.');
}
$branches = parse_branch_list((string) getenv('INSTALL_BRANCHES'));
$repository = parse_github_repository((string) getenv('INSTALL_GITHUB_REPOSITORY'));
// The selectable development tools, all enabled by default.
$tool_options = [
'phpcs' => 'PHPCS',
'phpstan' => 'PHPStan',
'rector' => 'Rector',
'twigcs' => 'Twig CS Fixer',
'eslint' => 'ESLint',
'stylelint' => 'Stylelint',
'cspell' => 'CSpell',
'jest' => 'Jest',
'phpunit' => 'PHPUnit',
'functional_javascript' => 'FunctionalJavascript tests',
'renovate' => 'Renovate',
];
$results = Prompty::flow(
fn(): array => [
'name' => Prompty::text('Extension name', placeholder: 'My Extension'),
'machine_name' => Prompty::text('Machine name', placeholder: 'my_extension'),
'type' => Prompty::select('Extension type', options: [
'module' => 'Module',
'theme' => 'Theme',
]),
'drupal_version' => Prompty::multiselect(
'Target Drupal versions',
options: drupal_version_options(),
default: drupal_version_default(),
description: 'CI runs against every selected major. Check any other major you also support.',
),
'command_wrapper' => Prompty::multiselect('Command wrapper', options: [
'ahoy' => 'Ahoy',
'makefile' => 'Makefile',
]),
'tools' => Prompty::multiselect(
'Tools',
options: $tool_options,
default: array_keys($tool_options),
description: 'All tools are included by default. Uncheck any to remove from your project.',
),
'examples' => Prompty::confirm(
'Keep example lifecycle scripts',
default: FALSE,
description: 'Sample hooks that only print a marker line when each build phase runs. Keep them as a starting point for your own scripts.',
),
'remove_self' => Prompty::confirm('Remove this script'),
'proceed' => Prompty::confirm('Proceed with installation'),
],
intro: 'Eddy - Drupal extension scaffold',
cancelled: 'Cancelled.',
numbering: TRUE,
env_prefix: 'INSTALL_',
);
if ($results === NULL || !($results['proceed'] ?? FALSE)) {
throw new \Exception('Aborting.');
}
$name = (string) $results['name'];
$machine_name = (string) $results['machine_name'];
$type = (string) $results['type'];
/** @var array<string> $drupal_versions */
$drupal_versions = array_filter((array) $results['drupal_version'], static fn($v): bool => $v !== '');
/** @var array<string> $command_wrapper */
$command_wrapper = array_filter((array) $results['command_wrapper'], static fn($v): bool => $v !== '');
/** @var array<string> $tools_keep */
$tools_keep = array_filter((array) $results['tools'], static fn($v): bool => $v !== '');
$tools_remove = array_values(array_diff(array_keys($tool_options), $tools_keep));
// The prompt asks whether to keep the example scripts, so a 'no' answer is
// what triggers their removal.
$remove_examples = !($results['examples'] ?? FALSE);
$remove_self = $results['remove_self'] ?? FALSE;
// Derive machine name from extension name when the placeholder was kept
// or the machine name was left empty.
if ($machine_name === 'my_extension' || $machine_name === '') {
$machine_name = convert_string($name, 'file_name');
}
process($name, $machine_name, $type, $drupal_versions, $command_wrapper, $tools_remove, $remove_examples, $remove_self, $branches, template_version(TEMPLATE_VERSION), $repository);
// The flow shows each answer as given, so a derived machine name appears
// only here.
print implode(PHP_EOL, [
'Name: ' . $name,
'Machine name: ' . $machine_name,
'Type: ' . $type,
'Drupal: ' . implode(', ', $drupal_versions),
'Wrapper: ' . implode(', ', $command_wrapper ?: ['None']),
'Removed tools: ' . implode(', ', $tools_remove ?: ['None']),
]) . PHP_EOL;
}
/**
* Define the selectable Drupal major versions.
*
* The canonical list of supported majors, used both to build the
* installation prompt and to prune deselected majors in 'process()'.
*
* Extending support takes a new entry here and '#;< DRUPAL_<major>' blocks
* wherever the existing majors have them. '.eddy/CLAUDE.md' lists every step.
*
* @return non-empty-array<int, string>
* Map of major version to its human-readable label. PHP casts the
* numeric-string keys to integers.
*/
function drupal_version_options(): array {
return [
'10' => 'Drupal 10',
'11' => 'Drupal 11',
'12' => 'Drupal 12',
];
}
/**
* Define the Drupal majors pre-selected in the installation prompt.
*
* A new extension targets current Drupal, so only the newest stable major
* starts checked; older majors and a pre-release major are opted into. The
* highest of these majors is the newest one with a stable release, which the
* shipped 'composer.dev.json' sets as the build default.
*
* @return non-empty-array<int, string>
* The majors to start checked.
*/
function drupal_version_default(): array {
return ['11'];
}
/**
* Pick the Drupal major that a local build uses by default.
*
* A major without a stable release can require a newer PHP than the other
* majors, so the default is the highest selected major with a stable release.
* When every selected major is newer, it is the lowest selected major.
*
* @param non-empty-array<int|string> $majors
* The selected Drupal majors, in any order.
*
* @return string
* The major, such as '11'.
*/
function drupal_version_build_default(array $majors): string {
$newest_stable = max(array_map(intval(...), drupal_version_default()));
$majors = array_map(intval(...), $majors);
$stable = array_filter($majors, static fn(int $major): bool => $major <= $newest_stable);
return (string) ($stable === [] ? min($majors) : max($stable));
}
/**
* Define the removal footprint of each Drupal major beyond its blocks.
*
* A file with a 'DRUPAL_<major>' block holds one for every major, so a file
* or text that only 1 major has is listed here instead. 'files' lists the
* files that go with the major, and 'replacements' maps the template text
* that names them to the text without them.
*
* '.eddy/CLAUDE.md' lists the steps for adding a Drupal major.
*
* @return array<int, array{
* files: list<string>,
* replacements: array<string, string>,
* }>
* Map of Drupal major to its removal specification. PHP casts the
* numeric-string keys to integers.
*/
function drupal_version_specs(): array {
return [
'10' => [
'files' => ['phpunit.d10.xml'],
'replacements' => [
'# phpunit.d10.xml export-ignore' . "\n" => '',
'run the tests, and `phpunit.d10.xml` replaces it in a Drupal 10 build. Both use' => 'run the tests. It uses',
],
],
];
}
/**
* Define the PHP versions that each Drupal major supports.
*
* The 'test' matrix of '.github/workflows/test.yml' runs each major on the
* lowest and the highest of its versions.
*
* @return non-empty-array<int, non-empty-list<string>>
* Map of Drupal major to its PHP versions, in ascending order. PHP casts the
* numeric-string keys to integers.
*/
function drupal_version_php_versions(): array {
return [
'10' => ['8.3', '8.4'],
'11' => ['8.3', '8.4', '8.5'],
'12' => ['8.5'],
];
}
/**
* Define the removal footprint of each extension type beyond its blocks.
*
* An 'EXTENSION_<TYPE>' block holds the lines that only that type has. 'files'
* lists the template files that only that type uses, and 'cspell_words' the
* words they bring into the CSpell 'words' of '.cspell.json'.
*
* The example extension is a module, so the files of its service, its settings
* form and their tests are the module's. A theme's own file is its '.theme'
* file.
*
* '.eddy/CLAUDE.md' describes what a theme keeps.
*
* @return array<string, array{
* files: list<string>,
* cspell_words: list<string>,
* }>
* Map of extension type to its removal specification.
*/
function extension_type_specs(): array {
return [
'module' => [
'files' => [
'your_extension.install',
'your_extension.links.menu.yml',
'your_extension.module',
'your_extension.routing.yml',
'your_extension.services.yml',
'config/schema/your_extension.schema.yml',
'src/Form/YourExtensionForm.php',
'src/YourExtensionService.php',
'tests/src/Functional/YourExtensionFunctionalTest.php',
'tests/src/Kernel/YourExtensionFormKernelTest.php',
'tests/src/Kernel/YourExtensionHooksKernelTest.php',
'tests/src/Kernel/YourExtensionServiceKernelTest.php',
'tests/src/Unit/YourExtensionServiceUnitTest.php',
],
'cspell_words' => ['noscript'],
],
'theme' => [
'files' => ['your_extension.theme', 'tests/src/Unit/YourExtensionPreprocessUnitTest.php'],
'cspell_words' => [],
],
];
}
/**
* Print help.
*/
function print_help(): void {
$script_name = basename(__FILE__);
$out = <<<EOF
Eddy - project installation.
----------------------------
Usage:
php {$script_name}
Run it in a copy of the template. In an empty directory, it downloads its own
version of the template first.
Options:
--help This help.
--version The Eddy version that the installer installs.
Environment variables (to pre-fill prompts):
INSTALL_NAME Extension name.
INSTALL_MACHINE_NAME Extension machine name.
INSTALL_TYPE Extension type: module or theme.
INSTALL_DRUPAL_VERSION Target Drupal majors: comma-separated (e.g. 11).
Drupal 11 is targeted by default; CI runs against
every selected major. One or more of: 10, 11, 12.
INSTALL_COMMAND_WRAPPER Command wrapper: ahoy, makefile, or both
(comma-separated).
INSTALL_TOOLS Tools to keep: comma-separated. All are kept by
default; list only the ones to keep to drop the rest.
One or more of: phpcs, phpstan, rector, twigcs,
eslint, stylelint, cspell, jest, phpunit,
functional_javascript, renovate.
INSTALL_EXAMPLES Keep example lifecycle scripts: true or false. They
are removed by default.
INSTALL_REMOVE_SELF Remove this script: true or false.
INSTALL_PROCEED Proceed with installation: true or false.
Other environment variables:
INSTALL_BRANCHES Branches the workflows run on: comma-separated
(e.g. 1.x,2.x). Defaults to the push branches of the
committed .github/workflows/test.yml, or 1.x.
INSTALL_GITHUB_REPOSITORY
GitHub repository the README badges name, as
owner/name (e.g. drevops/eddy). Defaults to the
GitHub origin remote of the clone this directory is
the root of, or to the machine name.
INSTALL_TEMPLATE_ARCHIVE
The .tar.gz archive to download in an empty
directory, as a URL or a path, with the template in
1 top-level directory, as GitHub builds it. Defaults
to the GitHub archive of the installer's version.
EOF;
print $out;
}
/**
* Print the version of the template that the installer installs.
*
* A copy without a version, such as a clone's, prints 'development'.
*/
function print_version(): void {
print (template_version(TEMPLATE_VERSION) ?? 'development') . PHP_EOL;
}
/**
* Process the project installation.
*
* @param string $extension_name
* The human-readable extension name.
* @param string $extension_machine_name
* The machine name of the extension.
* @param string $extension_type
* The extension type (module or theme).
* @param array<string> $drupal_versions
* The selected Drupal major versions to target (e.g. '10', '11').
* @param array<string> $command_wrapper
* The selected command wrappers ('ahoy', 'makefile', or both).
* @param array<string> $tools_remove
* The machine names of the development tools to remove.
* @param bool $remove_examples
* Whether to remove the example lifecycle scripts.
* @param bool $remove_self
* Whether to remove this script.
* @param array<int, string> $branches
* The branches the shipped workflows run on, or an empty array to read them
* from the committed workflow.
* @param string|null $version
* The template version to record, or NULL to record none.
* @param string|null $repository
* The GitHub repository the README badges name, as 'owner/name', or NULL to
* read it from the 'origin' remote.
*/
function process(string $extension_name, string $extension_machine_name, string $extension_type, array $drupal_versions, array $command_wrapper, array $tools_remove, bool $remove_examples, bool $remove_self, array $branches = [], ?string $version = NULL, ?string $repository = NULL): void {
// Validate required values.
if ($extension_name === '') {
throw new \Exception('Name is required.');
}
if ($extension_machine_name === '') {
throw new \Exception('Machine name is required.');
}
if (!preg_match('/^[a-z][a-z0-9_]*$/', $extension_machine_name)) {
throw new \Exception('Machine name must start with a lowercase letter and contain only lowercase letters, digits, and underscores.');
}
if ($extension_type === '') {
throw new \Exception('Type is required.');
}
if (!array_key_exists($extension_type, extension_type_specs())) {
throw new \Exception(sprintf('Unsupported extension type: %s.', $extension_type));
}
if ($drupal_versions === []) {
throw new \Exception('At least one Drupal version is required.');
}
// Normalize both sides to strings: PHP casts numeric-string array keys to
// integers, so 'array_keys()' returns ints that would never strictly match
// the string selection.
$supported_majors = array_map(strval(...), array_keys(drupal_version_options()));
$selected_majors = array_map(strval(...), $drupal_versions);
$unsupported_majors = array_diff($selected_majors, $supported_majors);
if ($unsupported_majors !== []) {
throw new \Exception(sprintf('Unsupported Drupal version: %s.', implode(', ', $unsupported_majors)));
}
process_drupal_version_text($selected_majors);
// 'process_drupal_version_text()' finds the majors with a PHPUnit
// configuration of their own by their files, so the files go after it.
remove_drupal_versions(array_diff($supported_majors, $selected_majors));
set_composer_dev_eddy_value('drupal-version', drupal_version_build_default($selected_majors));
if ($version !== NULL) {
set_composer_dev_eddy_value('version', $version);
}
// Remove unwanted command wrappers and their wrapper-specific documentation
// blocks (marked with '#;< DEV_AHOY' / '#;< DEV_MAKEFILE').
if (!in_array('ahoy', $command_wrapper, TRUE)) {
@unlink('.ahoy.yml');
remove_tokens_with_content('DEV_AHOY');
}
if (!in_array('makefile', $command_wrapper, TRUE)) {
@unlink('Makefile');
remove_tokens_with_content('DEV_MAKEFILE');
}
// '#;< DEV_COMMAND_WRAPPER' marks documentation for any kept wrapper.
// '#;< DEV_NO_COMMAND_WRAPPER' marks the direct commands that replace it
// when no wrapper is kept.
if ($command_wrapper === []) {
remove_tokens_with_content('DEV_COMMAND_WRAPPER');
}
else {
remove_tokens_with_content('DEV_NO_COMMAND_WRAPPER');
}
process_command_wrapper_text($command_wrapper);
trim_claude_settings_permissions($command_wrapper);
remove_tools($tools_remove);
process_readme($extension_name);
// 'extension_type_specs()' names the template files, so they go before
// 'process_internal()' renames them.
$types_remove = array_values(array_diff(array_keys(extension_type_specs()), [$extension_type]));
remove_extension_types($types_remove);
process_extension_type_text($extension_type);
process_internal($extension_name, $extension_machine_name, $extension_type, $repository ?? origin_github_repository());
process_workflow_branches($branches);
// Remove the sample lifecycle hooks. They demonstrate the naming convention
// and print a marker line, so they carry no project behaviour. The 'scripts'
// directory itself stays as the home for project-local hooks.
if ($remove_examples) {
@unlink('scripts/assemble-example.sh');
@unlink('scripts/provision-example.sh');
@unlink('scripts/start-example.sh');
@unlink('scripts/stop-example.sh');
}
if ($remove_self) {
// @codeCoverageIgnoreStart
@unlink(__FILE__);
// @codeCoverageIgnoreEnd
}
// This runs after every other change, so the words in use come from the
// files the project ships.
remove_unused_cspell_words([...tool_cspell_words($tools_remove), ...extension_type_cspell_words($types_remove)]);
}
/**
* Render the text that follows from the targeted Drupal majors.
*
* The template names every supported major in the 'core_version_requirement'
* of the extension and in the comment that opens 'phpunit.xml', and its
* README shows a badge for every PHP version they support. Each is replaced
* with the same text for the selected majors.
*
* @param array<string> $majors
* The selected Drupal majors.
*/
function process_drupal_version_text(array $majors): void {
$supported = array_map(strval(...), array_keys(drupal_version_options()));
replace_string_content(core_version_requirement($supported), core_version_requirement($majors));
replace_string_content(php_badges($supported), php_badges($majors));
// A 'phpunit.d<major>.xml' replaces 'phpunit.xml' in the build of its major.
$own_config_majors = array_filter($supported, static fn(string $major): bool => file_exists('phpunit.d' . $major . '.xml'));
replace_string_content(phpunit_config_comment(array_diff($supported, $own_config_majors)), phpunit_config_comment(array_diff($majors, $own_config_majors)));
}
/**
* Remove deselected Drupal majors from the project.
*
* Removing a major strips its '#;< DRUPAL_<major> ... #;> DRUPAL_<major>'
* blocks from every project file. Markers for kept majors are stripped later
* by 'remove_special_comments()'.
*
* It also removes the major's files listed in 'drupal_version_specs()' and
* rewrites the text in its 'replacements'.
*
* @param array<string> $majors
* The Drupal majors to remove.
*/
function remove_drupal_versions(array $majors): void {
$specs = drupal_version_specs();
foreach ($majors as $major) {
remove_tokens_with_content('DRUPAL_' . $major);
if (!isset($specs[$major])) {
continue;
}
foreach ($specs[$major]['files'] as $file) {
@unlink($file);
}
foreach ($specs[$major]['replacements'] as $search => $replacement) {
replace_string_content($search, $replacement);
}
}
}
/**
* Remove the unselected extension types from the project.
*
* Removing a type strips its '#;< EXTENSION_<TYPE> ... #;> EXTENSION_<TYPE>'
* blocks from every project file. Markers for the kept type are stripped later
* by 'remove_special_comments()'.
*
* It also removes the type's files listed in 'extension_type_specs()', with
* each directory that the removal leaves empty.
*
* @param array<string> $types
* The extension types to remove.
*/
function remove_extension_types(array $types): void {
$specs = extension_type_specs();
foreach ($types as $type) {
remove_tokens_with_content('EXTENSION_' . strtoupper($type));
foreach ($specs[$type]['files'] ?? [] as $file) {
remove_file($file);
}
}
}
/**
* Render the text that names the selected command wrappers.
*
* The template names both wrappers in phrases such as "`make build` or
* `ahoy build`". Each is replaced with the same phrase for the selected
* wrappers. Without a wrapper, these phrases sit in 'DEV_COMMAND_WRAPPER'
* blocks instead, so only the 'AGENTS.md' note on what runs the tooling
* installer changes.
*
* @param array<string> $command_wrapper
* The selected command wrappers.
*/
function process_command_wrapper_text(array $command_wrapper): void {
if ($command_wrapper === []) {
replace_string_content('The command wrappers and CI run it', 'CI runs it');
return;
}
$wrappers = array_keys(command_wrapper_binaries());
foreach (['', 'build', 'info', 'lint', 'test'] as $command) {
replace_string_content(command_wrapper_text($wrappers, $command), command_wrapper_text($command_wrapper, $command));
}
}
/**
* Render the text that follows the extension type.
*
* The template names the directories of both extension types, in the docs and
* on the lines of 'package.json' and 'jest.config.js' that list both, where no
* marker block fits. Each mention is replaced with the directory of the
* extension's type.
*
* The FunctionalJavascript base class enables the extension as a module, with
* Stark as the default theme. A theme cannot be enabled as a module, so its
* base class installs it as the default theme instead.
*
* @param string $extension_type
* The extension type: 'module' or 'theme'.
*/
function process_extension_type_text(string $extension_type): void {
$dir = $extension_type === 'theme' ? 'themes' : 'modules';
replace_string_content(' (`web/themes/custom/` for a theme)', '');
replace_string_content('`build/web/modules/custom/` (module) or `build/web/themes/custom/` (theme)', '`build/web/' . $dir . '/custom/`');
replace_string_content('web/modules/custom/your_extension/tests/', 'web/' . $dir . '/custom/your_extension/tests/');
replace_string_content('web/modules/custom web/themes/custom', 'web/' . $dir . '/custom');
replace_string_content('\"web/modules/custom/**/*.css\" \"web/themes/custom/**/*.css\"', '\"web/' . $dir . '/custom/**/*.css\"');
replace_string_content("['web/modules/custom', 'web/themes/custom']", "['web/" . $dir . "/custom']");
if ($extension_type === 'theme') {
$module_setup = implode("\n", [
" protected \$defaultTheme = 'stark';",
'',
' /**',
' * {@inheritdoc}',
' */',
" protected static \$modules = ['your_extension'];",
]);
replace_string_content($module_setup, " protected \$defaultTheme = 'your_extension';");
}
}
/**
* Render the 'core_version_requirement' of an extension.
*
* @param array<int|string> $majors
* The Drupal majors, in any order.
*
* @return string
* The line, such as 'core_version_requirement: ^10 || ^11'.
*/
function core_version_requirement(array $majors): string {
$constraints = array_map(static fn(string $major): string => '^' . $major, sort_drupal_majors($majors));
return 'core_version_requirement: ' . implode(' || ', $constraints);
}
/**
* Render the comment that opens 'phpunit.xml'.
*
* @param array<int|string> $majors
* The Drupal majors whose builds use 'phpunit.xml', in any order.
*
* @return string
* The comment, such as '<!-- Drupal 11 and 12 PHPUnit configuration. -->'.
* It names no major when there are none.
*/
function phpunit_config_comment(array $majors): string {
$majors = sort_drupal_majors($majors);
if ($majors === []) {
return '<!-- PHPUnit configuration. -->';
}
return '<!-- Drupal ' . join_list($majors) . ' PHPUnit configuration. -->';
}
/**
* Render the README badges of the PHP versions that Drupal majors support.
*
* @param array<int|string> $majors
* The Drupal majors, in any order.
*
* @return string
* The badge lines, 1 per PHP version in ascending order, or an empty string
* for no majors.
*/
function php_badges(array $majors): string {
$php_versions = drupal_version_php_versions();
$versions = [];
foreach ($majors as $major) {
$versions = array_merge($versions, $php_versions[(int) $major] ?? []);
}
$badges = array_map(static fn(string $version): string => '', sort_php_versions($versions));
return implode("\n", $badges);
}
/**
* Sort Drupal majors in numeric order and drop duplicates.
*
* @param array<int|string> $majors
* The Drupal majors.
*
* @return list<string>
* The sorted majors.
*/
function sort_drupal_majors(array $majors): array {
$majors = array_unique(array_map(intval(...), $majors));
sort($majors);
return array_map(strval(...), $majors);
}
/**
* Sort PHP versions in ascending order and drop duplicates.
*
* @param array<string> $versions
* The PHP versions, such as '8.3'.
*
* @return list<string>
* The sorted versions.
*/
function sort_php_versions(array $versions): array {
$versions = array_unique($versions);
sort($versions, SORT_NATURAL);
return $versions;
}
/**
* Map each command wrapper to the binary that runs it.
*
* @return array<string, string>
* The binaries, keyed by wrapper, in the order the template names them.
*/
function command_wrapper_binaries(): array {
return [
'makefile' => 'make',
'ahoy' => 'ahoy',
];
}
/**
* Render a command for each of the given command wrappers.
*
* @param array<string> $command_wrapper
* The command wrappers.
* @param string $command
* The command, or an empty string for the binaries alone.
*
* @return string
* The commands, such as "`make build` or `ahoy build`".
*/
function command_wrapper_text(array $command_wrapper, string $command = ''): string {
$commands = [];
foreach (command_wrapper_binaries() as $wrapper => $binary) {
if (in_array($wrapper, $command_wrapper, TRUE)) {
$commands[] = '`' . ($command === '' ? $binary : $binary . ' ' . $command) . '`';
}
}
return join_list($commands, 'or');
}
/**
* Join items into a list for a sentence, such as 'A, B and C'.
*
* @param array<string> $items
* The items.
* @param string $conjunction
* The word before the last item.
*
* @return string
* The list, or an empty string for no items.
*/
function join_list(array $items, string $conjunction = 'and'): string {
$items = array_values($items);
$last = array_pop($items);
if ($last === NULL) {
return '';
}
if ($items === []) {
return $last;
}
return implode(', ', $items) . ' ' . $conjunction . ' ' . $last;
}
/**
* Trim wrapper-specific permissions from Claude settings to match selection.
*
* A failed read (file exists but is unreadable) is treated the same as a
* missing file: the settings are left untouched rather than aborting the
* whole 'process()' pipeline over a single non-critical file.
*
* @param array<string> $command_wrapper
* The selected command wrappers ('ahoy', 'makefile', or both).
*/
function trim_claude_settings_permissions(array $command_wrapper): void {
$file = '.claude/settings.json';
if (!file_exists($file)) {
return;
}
$raw = file_get_contents($file);
if ($raw === FALSE) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
$config = json_decode($raw, TRUE, 512, JSON_THROW_ON_ERROR);
if (!is_array($config) || !isset($config['permissions']) || !is_array($config['permissions']) || !isset($config['permissions']['allow']) || !is_array($config['permissions']['allow'])) {
throw new \RuntimeException('Invalid .claude/settings.json structure.');
}
$config['permissions']['allow'] = array_values(array_filter(
$config['permissions']['allow'],
static function ($permission) use ($command_wrapper): bool {
$wrapper = match ($permission) {
'Bash(ahoy:*)' => 'ahoy',
'Bash(make:*)' => 'makefile',
default => NULL,
};
return $wrapper === NULL || in_array($wrapper, $command_wrapper, TRUE);
},
));
$encoded = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($file, $encoded . PHP_EOL) === FALSE) {
// @codeCoverageIgnoreStart
throw new \RuntimeException('Unable to write .claude/settings.json.');
// @codeCoverageIgnoreEnd
}
}
/**
* Process the README and CONTRIBUTING files and download placeholder logo.
*
* @param string $extension_name
* The human-readable extension name.
*/
function process_readme(string $extension_name): void {
@rename('README.dist.md', 'README.md');
@rename('CONTRIBUTING.dist.md', 'CONTRIBUTING.md');
$url = 'https://placehold.jp/000000/ffffff/200x200.png?text=' . str_replace(' ', '+', $extension_name) . '&css=%7B%22border-radius%22%3A%22%20100px%22%7D';
$logo = @file_get_contents($url);
if ($logo !== FALSE && $logo !== '') {
file_put_contents('logo.png', $logo);
}
}
/**
* Process internal replacements, renames, and cleanups.
*
* @param string $extension_name
* The human-readable extension name.
* @param string $extension_machine_name
* The machine name of the extension.
* @param string $extension_type
* The extension type (module or theme).
* @param string|null $repository
* The GitHub repository the README badges name, as 'owner/name', or NULL
* to name the machine name for both parts.
*/
function process_internal(string $extension_name, string $extension_machine_name, string $extension_type, ?string $repository = NULL): void {
$extension_machine_name_class = convert_string($extension_machine_name, 'class_name');
// Protect the attribution link from bulk replacements.
$attribution_link = '[Eddy](https://github.com/drevops/eddy)';
$attribution_link_token = '__ATTRIBUTION_LINK__';
replace_string_content($attribution_link, $attribution_link_token);
// A repository can hold a placeholder, such as the owner 'alexskrypnyk',
// so it goes in after the bulk replacements.
$repository_token = '__GITHUB_REPOSITORY__';
if ($repository !== NULL) {
replace_string_content('YourNamespace/your_extension', $repository_token);
}
// Protect the update skill URL from bulk replacements.
$update_skill_url = 'https://raw.githubusercontent.com/drevops/eddy/main/.eddy/skills/update-consumer-eddy/SKILL.md';
$update_skill_url_token = '__UPDATE_SKILL_URL__';
replace_string_content($update_skill_url, $update_skill_url_token);
replace_string_content('YourNamespace', $extension_machine_name);
replace_string_content('yournamespace', $extension_machine_name);
replace_string_content('alexskrypnyk', $extension_machine_name);
replace_string_content('yourproject', $extension_machine_name);
replace_string_content('Yourproject logo', $extension_name . ' logo');
replace_string_content('Your Extension', $extension_name);
replace_string_content('your extension', $extension_name);
replace_string_content('Your+Extension', $extension_machine_name);
replace_string_content('your_extension', $extension_machine_name);
replace_string_content('your-extension', $extension_machine_name);
replace_string_content('YourExtension', $extension_machine_name_class);
replace_string_content('Provides your_extension functionality.', 'Provides ' . $extension_machine_name . ' functionality.');
replace_string_content('drupal-module', 'drupal-' . $extension_type);
replace_string_content('Drupal module scaffold FE example used for template testing.', 'Provides ' . $extension_machine_name . ' functionality.');
replace_string_content('Drupal extension scaffold', $extension_name);
replace_string_content('type: module', 'type: ' . $extension_type);
replace_string_content('is a Drupal module.', 'is a Drupal ' . $extension_type . '.');
replace_string_content('[EXTENSION_NAME]', $extension_machine_name);
// Restore the attribution link.
replace_string_content($attribution_link_token, $attribution_link);
if ($repository !== NULL) {
replace_string_content($repository_token, $repository);
}