Passed
Pull Request — master (#88)
by Théo
02:23
created

Compile::loadBootstrapFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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

391
                    /** @scrutinizer ignore-type */ array_search($algorithm, get_phar_compression_algorithms(), true)
Loading history...
392
                )
393
            );
394
395
            $box->getPhar()->compressFiles($algorithm);
396
        } else {
397
            $logger->log(
398
                BuildLogger::QUESTION_MARK_PREFIX,
399
                '<error>No compression</error>'
400
            );
401
        }
402
    }
403
404
    private function signPhar(
405
        Configuration $config,
406
        Box $box,
407
        string $path,
408
        InputInterface $input,
409
        OutputInterface $output,
410
        BuildLogger $logger
411
    ): void {
412
        // sign using private key, if applicable
413
        //TODO: check that out
414
        remove($path.'.pubkey');
415
416
        $key = $config->getPrivateKeyPath();
417
418
        if (null === $key) {
419
            if (null !== ($algorithm = $config->getSigningAlgorithm())) {
0 ignored issues
show
introduced by
The condition null !== $algorithm = $c...->getSigningAlgorithm() can never be false.
Loading history...
420
                $box->getPhar()->setSignatureAlgorithm($algorithm);
421
            }
422
423
            return;
424
        }
425
426
        $logger->log(
427
            BuildLogger::QUESTION_MARK_PREFIX,
428
            'Signing using a private key'
429
        );
430
431
        $passphrase = $config->getPrivateKeyPassphrase();
432
433
        if ($config->isPrivateKeyPrompt()) {
434
            if (false === $input->isInteractive()) {
435
                throw new RuntimeException(
436
                    sprintf(
437
                        'Accessing to the private key "%s" requires a passphrase but none provided. Either '
438
                        .'provide one or run this command in interactive mode.',
439
                        $key
440
                    )
441
                );
442
            }
443
444
            /** @var $dialog QuestionHelper */
445
            $dialog = $this->getHelper('question');
446
447
            $question = new Question('Private key passphrase:');
448
            $question->setHidden(false);
449
            $question->setHiddenFallback(false);
450
451
            $passphrase = $dialog->ask($input, $output, $question);
452
453
            $output->writeln('');
454
        }
455
456
        $box->signUsingFile($key, $passphrase);
457
    }
458
459
    private function correctPermissions(string $path, Configuration $config, BuildLogger $logger): void
460
    {
461
        if (null !== ($chmod = $config->getFileMode())) {
462
            $logger->log(
463
                BuildLogger::QUESTION_MARK_PREFIX,
464
                "Setting file permissions to <comment>$chmod</comment>"
465
            );
466
467
            chmod($path, $chmod);
468
        }
469
    }
470
471
    private function createStub(Configuration $config, ?string $main, BuildLogger $logger): StubGenerator
472
    {
473
        $stub = StubGenerator::create()
474
            ->alias($config->getAlias())
475
            ->index($main)
476
            ->intercept($config->isInterceptFileFuncs())
477
        ;
478
479
        if (null !== ($shebang = $config->getShebang())) {
480
            $logger->log(
481
                BuildLogger::MINUS_PREFIX,
482
                sprintf(
483
                    'Using shebang line: %s',
484
                    $shebang
485
                )
486
            );
487
488
            $stub->shebang($shebang);
489
        } else {
490
            $logger->log(
491
                BuildLogger::MINUS_PREFIX,
492
                'No shebang line'
493
            );
494
        }
495
496
        if (null !== ($bannerPath = $config->getStubBannerPath())) {
497
            $logger->log(
498
                BuildLogger::MINUS_PREFIX,
499
                sprintf(
500
                    'Using custom banner from file: %s',
501
                    $bannerPath
502
                )
503
            );
504
505
            $stub->banner($config->getStubBannerContents());
506
        } elseif (null !== ($banner = $config->getStubBannerContents())) {
507
            $logger->log(
508
                BuildLogger::MINUS_PREFIX,
509
                'Using banner:'
510
            );
511
512
            $bannerLines = explode("\n", $banner);
513
514
            foreach ($bannerLines as $bannerLine) {
515
                $logger->log(
516
                    BuildLogger::CHEVRON_PREFIX,
517
                    $bannerLine
518
                );
519
            }
520
521
            $stub->banner($banner);
522
        }
523
524
        return $stub;
525
    }
526
527
    private function logMap(MapFile $fileMapper, BuildLogger $logger): void
528
    {
529
        $map = $fileMapper->getMap();
530
531
        if ([] === $map) {
532
            return;
533
        }
534
535
        $logger->log(
536
            BuildLogger::QUESTION_MARK_PREFIX,
537
            'Mapping paths'
538
        );
539
540
        foreach ($map as $item) {
541
            foreach ($item as $match => $replace) {
542
                if ('' === $match) {
543
                    $match = '(all)';
544
                    $replace .= '/';
545
                }
546
547
                $logger->log(
548
                    BuildLogger::MINUS_PREFIX,
549
                    sprintf(
550
                        '%s <info>></info> %s',
551
                        $match,
552
                        $replace
553
                    )
554
                );
555
            }
556
        }
557
    }
558
}
559