Passed
Pull Request — master (#129)
by Théo
02:17
created

Compile::configure()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 28
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 28
rs 8.8571
c 0
b 0
f 0
cc 1
eloc 20
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the box project.
7
 *
8
 * (c) Kevin Herrera <[email protected]>
9
 *     Théo Fidry <[email protected]>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14
15
namespace KevinGH\Box\Console\Command;
16
17
use Amp\MultiReasonException;
18
use Assert\Assertion;
19
use DateTimeImmutable;
20
use DateTimeZone;
21
use KevinGH\Box\Box;
22
use KevinGH\Box\Compactor;
23
use KevinGH\Box\Configuration;
24
use KevinGH\Box\Console\Logger\BuildLogger;
25
use KevinGH\Box\MapFile;
26
use KevinGH\Box\PhpSettingsHandler;
27
use KevinGH\Box\StubGenerator;
28
use RuntimeException;
29
use stdClass;
30
use Symfony\Component\Console\Helper\QuestionHelper;
31
use Symfony\Component\Console\Input\InputInterface;
32
use Symfony\Component\Console\Input\InputOption;
33
use Symfony\Component\Console\Logger\ConsoleLogger;
34
use Symfony\Component\Console\Output\OutputInterface;
35
use Symfony\Component\Console\Question\Question;
36
use Symfony\Component\Console\Style\SymfonyStyle;
37
use Symfony\Component\VarDumper\Cloner\VarCloner;
38
use Symfony\Component\VarDumper\Dumper\CliDumper;
39
use const DATE_ATOM;
40
use function count;
41
use function KevinGH\Box\enable_debug;
42
use function KevinGH\Box\FileSystem\chmod;
43
use function KevinGH\Box\FileSystem\dump_file;
44
use function KevinGH\Box\FileSystem\make_path_relative;
45
use function KevinGH\Box\FileSystem\remove;
46
use function KevinGH\Box\FileSystem\rename;
47
use function KevinGH\Box\formatted_filesize;
48
use function KevinGH\Box\get_phar_compression_algorithms;
49
use function KevinGH\Box\is_debug_enabled;
50
51
/**
52
 * @final
53
 * @private
54
 * TODO: make final when Build is removed
55
 */
56
class Compile extends Configurable
57
{
58
    use ChangeableWorkingDirectory;
59
60
    private const HELP = <<<'HELP'
61
The <info>%command.name%</info> command will compile code in a new PHAR based on a variety of settings.
62
<comment>
63
  This command relies on a configuration file for loading
64
  PHAR packaging settings. If a configuration file is not
65
  specified through the <info>--config|-c</info> option, one of
66
  the following files will be used (in order): <info>box.json</info>,
67
  <info>box.json.dist</info>
68
</comment>
69
The configuration file is actually a JSON object saved to a file. For more
70
information check the documentation online:
71
<comment>
72
  https://github.com/humbug/box
73
</comment>
74
HELP;
75
76
    private const DEBUG_OPTION = 'debug';
77
    private const DEV_OPTION = 'dev';
78
    private const NO_CONFIG_OPTION = 'no-config';
79
80
    /**
81
     * {@inheritdoc}
82
     */
83
    protected function configure(): void
84
    {
85
        parent::configure();
86
87
        $this->setName('compile');
88
        $this->setDescription('Compile an application into a PHAR');
89
        $this->setHelp(self::HELP);
90
91
        $this->addOption(
92
            self::DEBUG_OPTION,
93
            null,
94
            InputOption::VALUE_NONE,
95
            'Dump the files added to the PHAR in a `'.Box::DEBUG_DIR.'` directory'
96
        );
97
        $this->addOption(
98
            self::DEV_OPTION,
99
            null,
100
            InputOption::VALUE_NONE,
101
            'Skips the compression step'
102
        );
103
        $this->addOption(
104
            self::NO_CONFIG_OPTION,
105
            null,
106
            InputOption::VALUE_NONE,
107
            'Ignore the config file even when one is specified with the --config option'
108
        );
109
110
        $this->configureWorkingDirOption();
111
    }
112
113
    /**
114
     * {@inheritdoc}
115
     */
116
    protected function execute(InputInterface $input, OutputInterface $output): void
117
    {
118
        (new PhpSettingsHandler(new ConsoleLogger($output)))->check();
119
120
        if (true === $input->getOption(self::DEBUG_OPTION)) {
121
            enable_debug($output);
122
        }
123
124
        $this->changeWorkingDirectory($input);
125
126
        $io = new SymfonyStyle($input, $output);
127
128
        $io->writeln($this->getApplication()->getHelp());
129
        $io->writeln('');
130
131
        $config = $input->getOption(self::NO_CONFIG_OPTION)
132
            ? Configuration::create(null, new stdClass())
133
            : $this->getConfig($input, $output, true)
134
        ;
135
        $path = $config->getOutputPath();
136
137
        $logger = new BuildLogger($io);
138
139
        $startTime = microtime(true);
140
141
        $this->removeExistingArtefacts($config, $logger);
142
143
        $logger->logStartBuilding($path);
144
145
        $this->createPhar($config, $input, $output, $logger, $io);
146
147
        $this->correctPermissions($path, $config, $logger);
148
149
        $logger->log(
150
            BuildLogger::STAR_PREFIX,
151
            'Done.'
152
        );
153
154
        $io->comment(
155
            sprintf(
156
                "<info>PHAR size: %s\nMemory usage: %.2fMB (peak: %.2fMB), time: %.2fs<info>",
157
                formatted_filesize($path),
158
                round(memory_get_usage() / 1024 / 1024, 2),
159
                round(memory_get_peak_usage() / 1024 / 1024, 2),
160
                round(microtime(true) - $startTime, 2)
161
            )
162
        );
163
    }
164
165
    private function createPhar(
166
        Configuration $config,
167
        InputInterface $input,
168
        OutputInterface $output,
169
        BuildLogger $logger,
170
        SymfonyStyle $io
171
    ): void {
172
        $box = Box::create(
173
            $config->getTmpOutputPath()
174
        );
175
        $box->getPhar()->startBuffering();
176
177
        $this->setReplacementValues($config, $box, $logger);
178
        $this->registerCompactors($config, $box, $logger);
179
        $this->registerFileMapping($config, $box, $logger);
180
181
        // Registering the main script _before_ adding the rest if of the files is _very_ important. The temporary
182
        // file used for debugging purposes and the Composer dump autoloading will not work correctly otherwise.
183
        $main = $this->registerMainScript($config, $box, $logger);
184
185
        $this->addFiles($config, $box, $logger, $io);
186
187
        $this->registerStub($config, $box, $main, $logger);
188
        $this->configureMetadata($config, $box, $logger);
189
190
        $this->configureCompressionAlgorithm($config, $box, $input->getOption(self::DEV_OPTION), $logger);
191
192
        $box->getPhar()->stopBuffering();
193
194
        $this->signPhar($config, $box, $config->getTmpOutputPath(), $input, $output, $logger);
195
196
        if ($config->getTmpOutputPath() !== $config->getOutputPath()) {
197
            rename($config->getTmpOutputPath(), $config->getOutputPath());
198
        }
199
    }
200
201
    private function removeExistingArtefacts(Configuration $config, BuildLogger $logger): void
202
    {
203
        $path = $config->getOutputPath();
204
205
        if (is_debug_enabled()) {
206
            $date = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM);
207
            $file = null !== $config->getFile() ? $config->getFile() : 'No config file';
208
209
            remove(Box::DEBUG_DIR);
210
211
            dump_file(
212
                Box::DEBUG_DIR.'/.box_configuration',
213
                <<<EOF
214
//
215
// Processed content of the configuration file "$file" dumped for debugging purposes
216
// Time: $date
217
//
218
219
220
EOF
221
                .(new CliDumper())->dump(
222
                    (new VarCloner())->cloneVar($config),
223
                    true
224
                )
225
            );
226
        }
227
228
        if (false === file_exists($path)) {
229
            return;
230
        }
231
232
        $logger->log(
233
            BuildLogger::QUESTION_MARK_PREFIX,
234
            sprintf(
235
                'Removing the existing PHAR "%s"',
236
                $path
237
            )
238
        );
239
240
        remove($path);
241
    }
242
243
    private function setReplacementValues(Configuration $config, Box $box, BuildLogger $logger): void
244
    {
245
        $values = $config->getProcessedReplacements();
246
247
        if ([] === $values) {
248
            return;
249
        }
250
251
        $logger->log(
252
            BuildLogger::QUESTION_MARK_PREFIX,
253
            'Setting replacement values'
254
        );
255
256
        foreach ($values as $key => $value) {
257
            $logger->log(
258
                BuildLogger::PLUS_PREFIX,
259
                sprintf(
260
                    '%s: %s',
261
                    $key,
262
                    $value
263
                )
264
            );
265
        }
266
267
        $box->registerPlaceholders($values);
268
    }
269
270
    private function registerCompactors(Configuration $config, Box $box, BuildLogger $logger): void
271
    {
272
        $compactors = $config->getCompactors();
273
274
        if ([] === $compactors) {
275
            $logger->log(
276
                BuildLogger::QUESTION_MARK_PREFIX,
277
                'No compactor to register'
278
            );
279
280
            return;
281
        }
282
283
        $logger->log(
284
            BuildLogger::QUESTION_MARK_PREFIX,
285
            'Registering compactors'
286
        );
287
288
        $logCompactors = function (Compactor $compactor) use ($logger): void {
289
            $logger->log(
290
                BuildLogger::PLUS_PREFIX,
291
                get_class($compactor)
292
            );
293
        };
294
295
        array_map($logCompactors, $compactors);
296
297
        $box->registerCompactors($compactors);
298
    }
299
300
    private function registerFileMapping(Configuration $config, Box $box, BuildLogger $logger): void
301
    {
302
        $fileMapper = $config->getFileMapper();
303
304
        $this->logMap($fileMapper, $logger);
305
306
        $box->registerFileMapping(
307
            $config->getBasePath(),
308
            $fileMapper
309
        );
310
    }
311
312
    private function addFiles(Configuration $config, Box $box, BuildLogger $logger, SymfonyStyle $io): void
313
    {
314
        $logger->log(BuildLogger::QUESTION_MARK_PREFIX, 'Adding binary files');
315
316
        $count = count($config->getBinaryFiles());
317
318
        $box->addFiles($config->getBinaryFiles(), true);
319
320
        $logger->log(
321
            BuildLogger::CHEVRON_PREFIX,
322
            0 === $count
323
                ? 'No file found'
324
                : sprintf('%d file(s)', $count)
325
        );
326
327
        $logger->log(BuildLogger::QUESTION_MARK_PREFIX, 'Adding files');
328
329
        $count = count($config->getFiles());
330
331
        try {
332
            $box->addFiles($config->getFiles(), false, null !== $config->getComposerJson());
333
        } catch (MultiReasonException $exception) {
334
            // This exception is handled a different way to give me meaningful feedback to the user
335
            foreach ($exception->getReasons() as $reason) {
336
                $io->error($reason);
337
            }
338
339
            throw $exception;
340
        }
341
342
        $logger->log(
343
            BuildLogger::CHEVRON_PREFIX,
344
            0 === $count
345
                ? 'No file found'
346
                : sprintf('%d file(s)', $count)
347
        );
348
    }
349
350
    private function registerMainScript(Configuration $config, Box $box, BuildLogger $logger): ?string
351
    {
352
        $main = $config->getMainScriptPath();
353
354
        $logger->log(
355
            BuildLogger::QUESTION_MARK_PREFIX,
356
            sprintf(
357
                'Adding main file: %s',
358
                $main
359
            )
360
        );
361
362
        $localMain = $box->addFile(
363
            $main,
364
            $config->getMainScriptContents()
365
        );
366
367
        $relativeMain = make_path_relative($main, $config->getBasePath());
368
369
        if ($localMain !== $relativeMain) {
370
            $logger->log(
371
                BuildLogger::CHEVRON_PREFIX,
372
                $localMain
373
            );
374
        }
375
376
        return $localMain;
377
    }
378
379
    private function registerStub(Configuration $config, Box $box, string $main, BuildLogger $logger): void
380
    {
381
        if (true === $config->isStubGenerated()) {
382
            $logger->log(
383
                BuildLogger::QUESTION_MARK_PREFIX,
384
                'Generating new stub'
385
            );
386
387
            $stub = $this->createStub($config, $main, $logger);
388
389
            $box->getPhar()->setStub($stub->generate());
390
        } elseif (null !== ($stub = $config->getStubPath())) {
391
            $logger->log(
392
                BuildLogger::QUESTION_MARK_PREFIX,
393
                sprintf(
394
                    'Using stub file: %s',
395
                    $stub
396
                )
397
            );
398
399
            $box->registerStub($stub);
400
        } else {
401
            $aliasWasAdded = $box->getPhar()->setAlias($config->getAlias());
402
403
            Assertion::true(
404
                $aliasWasAdded,
405
                sprintf(
406
                    'The alias "%s" is invalid. See Phar::setAlias() documentation for more information.',
407
                    $config->getAlias()
408
                )
409
            );
410
411
            $box->getPhar()->setDefaultStub($main);
412
413
            $logger->log(
414
                BuildLogger::QUESTION_MARK_PREFIX,
415
                'Using default stub'
416
            );
417
        }
418
    }
419
420
    private function configureMetadata(Configuration $config, Box $box, BuildLogger $logger): void
421
    {
422
        if (null !== ($metadata = $config->getMetadata())) {
423
            $logger->log(
424
                BuildLogger::QUESTION_MARK_PREFIX,
425
                'Setting metadata'
426
            );
427
428
            $logger->log(
429
                BuildLogger::MINUS_PREFIX,
430
                is_string($metadata) ? $metadata : var_export($metadata, true)
431
            );
432
433
            $box->getPhar()->setMetadata($metadata);
434
        }
435
    }
436
437
    private function configureCompressionAlgorithm(Configuration $config, Box $box, bool $dev, BuildLogger $logger): void
438
    {
439
        if (null !== ($algorithm = $config->getCompressionAlgorithm())) {
440
            $logger->log(
441
                BuildLogger::QUESTION_MARK_PREFIX,
442
                sprintf(
443
                    'Compressing with the algorithm "<comment>%s</comment>"',
444
                    array_search($algorithm, get_phar_compression_algorithms(), true)
1 ignored issue
show
Bug introduced by
It seems like array_search($algorithm,...ion_algorithms(), true) can also be of type false; however, parameter $args of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

444
                    /** @scrutinizer ignore-type */ array_search($algorithm, get_phar_compression_algorithms(), true)
Loading history...
445
                )
446
            );
447
448
            $box->getPhar()->compressFiles($algorithm);
449
        } else {
450
            $logger->log(
451
                BuildLogger::QUESTION_MARK_PREFIX,
452
                $dev
453
                    ? 'No compression'
454
                    : '<error>No compression</error>'
455
            );
456
        }
457
    }
458
459
    private function signPhar(
460
        Configuration $config,
461
        Box $box,
462
        string $path,
463
        InputInterface $input,
464
        OutputInterface $output,
465
        BuildLogger $logger
466
    ): void {
467
        // sign using private key, if applicable
468
        //TODO: check that out
469
        remove($path.'.pubkey');
470
471
        $key = $config->getPrivateKeyPath();
472
473
        if (null === $key) {
474
            if (null !== ($algorithm = $config->getSigningAlgorithm())) {
0 ignored issues
show
introduced by
The condition null !== $algorithm = $c...->getSigningAlgorithm() is always true.
Loading history...
475
                $box->getPhar()->setSignatureAlgorithm($algorithm);
476
            }
477
478
            return;
479
        }
480
481
        $logger->log(
482
            BuildLogger::QUESTION_MARK_PREFIX,
483
            'Signing using a private key'
484
        );
485
486
        $passphrase = $config->getPrivateKeyPassphrase();
487
488
        if ($config->isPrivateKeyPrompt()) {
489
            if (false === $input->isInteractive()) {
490
                throw new RuntimeException(
491
                    sprintf(
492
                        'Accessing to the private key "%s" requires a passphrase but none provided. Either '
493
                        .'provide one or run this command in interactive mode.',
494
                        $key
495
                    )
496
                );
497
            }
498
499
            /** @var $dialog QuestionHelper */
500
            $dialog = $this->getHelper('question');
501
502
            $question = new Question('Private key passphrase:');
503
            $question->setHidden(false);
504
            $question->setHiddenFallback(false);
505
506
            $passphrase = $dialog->ask($input, $output, $question);
507
508
            $output->writeln('');
509
        }
510
511
        $box->signUsingFile($key, $passphrase);
512
    }
513
514
    private function correctPermissions(string $path, Configuration $config, BuildLogger $logger): void
515
    {
516
        if (null !== ($chmod = $config->getFileMode())) {
517
            $logger->log(
518
                BuildLogger::QUESTION_MARK_PREFIX,
519
                "Setting file permissions to <comment>$chmod</comment>"
520
            );
521
522
            chmod($path, $chmod);
523
        }
524
    }
525
526
    private function createStub(Configuration $config, ?string $main, BuildLogger $logger): StubGenerator
527
    {
528
        $stub = StubGenerator::create()
529
            ->alias($config->getAlias())
530
            ->index($main)
531
            ->intercept($config->isInterceptFileFuncs())
532
        ;
533
534
        if (null !== ($shebang = $config->getShebang())) {
535
            $logger->log(
536
                BuildLogger::MINUS_PREFIX,
537
                sprintf(
538
                    'Using shebang line: %s',
539
                    $shebang
540
                )
541
            );
542
543
            $stub->shebang($shebang);
544
        } else {
545
            $logger->log(
546
                BuildLogger::MINUS_PREFIX,
547
                'No shebang line'
548
            );
549
        }
550
551
        if (null !== ($bannerPath = $config->getStubBannerPath())) {
552
            $logger->log(
553
                BuildLogger::MINUS_PREFIX,
554
                sprintf(
555
                    'Using custom banner from file: %s',
556
                    $bannerPath
557
                )
558
            );
559
560
            $stub->banner($config->getStubBannerContents());
561
        } elseif (null !== ($banner = $config->getStubBannerContents())) {
562
            $logger->log(
563
                BuildLogger::MINUS_PREFIX,
564
                'Using banner:'
565
            );
566
567
            $bannerLines = explode("\n", $banner);
568
569
            foreach ($bannerLines as $bannerLine) {
570
                $logger->log(
571
                    BuildLogger::CHEVRON_PREFIX,
572
                    $bannerLine
573
                );
574
            }
575
576
            $stub->banner($banner);
577
        }
578
579
        return $stub;
580
    }
581
582
    private function logMap(MapFile $fileMapper, BuildLogger $logger): void
583
    {
584
        $map = $fileMapper->getMap();
585
586
        if ([] === $map) {
587
            return;
588
        }
589
590
        $logger->log(
591
            BuildLogger::QUESTION_MARK_PREFIX,
592
            'Mapping paths'
593
        );
594
595
        foreach ($map as $item) {
596
            foreach ($item as $match => $replace) {
597
                if ('' === $match) {
598
                    $match = '(all)';
599
                    $replace .= '/';
600
                }
601
602
                $logger->log(
603
                    BuildLogger::MINUS_PREFIX,
604
                    sprintf(
605
                        '%s <info>></info> %s',
606
                        $match,
607
                        $replace
608
                    )
609
                );
610
            }
611
        }
612
    }
613
}
614