• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JBZoo / Cli / 18073060279

28 Sep 2025 10:34AM UTC coverage: 80.982% (-0.3%) from 81.315%
18073060279

push

github

web-flow
build(deps): Upgrade Symfony to v7 and PHP to 8.2 (#30)

- Bump Symfony components (console, process, lock) to ^7.3.4.
- Increase minimum PHP requirement to 8.2.
- Replace external process manager with an internal
JBZoo\Cli\ProcessManager implementation to maintain compatibility with
Symfony 7.
- Update other core dependencies: jbzoo/utils, jbzoo/event,
monolog/monolog, jbzoo/toolbox-dev.
- Adjust CI workflows to support PHP 8.2+ and remove scheduled runs.
- Refactor various classes to be final and update type hints for
improved code quality and Psalm compatibility.
- Remove deprecated phpstan.neon configuration file.

65 of 78 new or added lines in 6 files covered. (83.33%)

15 existing lines in 3 files now uncovered.

1056 of 1304 relevant lines covered (80.98%)

229.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.95
/src/CliCommand.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Cli.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Cli
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\Cli;
18

19
use JBZoo\Cli\OutputMods\AbstractOutputMode;
20
use JBZoo\Cli\OutputMods\Cron;
21
use JBZoo\Cli\OutputMods\Logstash;
22
use JBZoo\Cli\OutputMods\Text;
23
use JBZoo\Cli\ProgressBars\AbstractProgressBar;
24
use JBZoo\Utils\Arr;
25
use JBZoo\Utils\Str;
26
use JBZoo\Utils\Vars;
27
use Symfony\Component\Console\Command\Command;
28
use Symfony\Component\Console\Helper\QuestionHelper;
29
use Symfony\Component\Console\Input\InputInterface;
30
use Symfony\Component\Console\Input\InputOption;
31
use Symfony\Component\Console\Output\OutputInterface;
32
use Symfony\Component\Console\Question\ChoiceQuestion;
33
use Symfony\Component\Console\Question\ConfirmationQuestion;
34
use Symfony\Component\Console\Question\Question;
35

36
use function JBZoo\Utils\bool;
37
use function JBZoo\Utils\float;
38
use function JBZoo\Utils\int;
39

40
abstract class CliCommand extends Command
41
{
42
    protected AbstractOutputMode $outputMode;
43

44
    abstract protected function executeAction(): int;
45

46
    /**
47
     * @psalm-suppress PossiblyUnusedReturnValue
48
     */
49
    public function progressBar(
50
        int|iterable $listOrMax,
51
        \Closure $callback,
52
        string $title = '',
53
        bool $throwBatchException = true,
54
        ?AbstractOutputMode $outputMode = null,
55
    ): AbstractProgressBar {
56
        static $nestedLevel = 0;
246✔
57

58
        $outputMode ??= $this->outputMode;
246✔
59

60
        $progressBar = $outputMode->createProgressBar()
246✔
61
            ->setTitle($title)
246✔
62
            ->setCallback($callback)
246✔
63
            ->setThrowBatchException($throwBatchException);
246✔
64

65
        if (\is_iterable($listOrMax)) {
246✔
66
            $progressBar->setList($listOrMax);
84✔
67
        } else {
68
            $progressBar->setMax($listOrMax);
162✔
69
        }
70

71
        $nestedLevel++;
246✔
72
        $progressBar->setNestedLevel($nestedLevel);
246✔
73

74
        $progressBar->execute();
246✔
75

76
        $nestedLevel--;
192✔
77
        $progressBar->setNestedLevel($nestedLevel);
192✔
78

79
        return $progressBar;
192✔
80
    }
81

82
    /**
83
     * {@inheritDoc}
84
     */
85
    protected function configure(): void
86
    {
87
        $this
846✔
88
            ->addOption(
846✔
89
                'no-progress',
846✔
90
                null,
846✔
91
                InputOption::VALUE_NONE,
846✔
92
                'Disable progress bar animation for logs. '
846✔
93
                . 'It will be used only for <info>' . Text::getName() . '</info> output format.',
846✔
94
            )
846✔
95
            ->addOption(
846✔
96
                'mute-errors',
846✔
97
                null,
846✔
98
                InputOption::VALUE_NONE,
846✔
99
                "Mute any sort of errors. So exit code will be always \"0\" (if it's possible).\n"
846✔
100
                . "It has major priority then <info>--non-zero-on-error</info>. It's on your own risk!",
846✔
101
            )
846✔
102
            ->addOption(
846✔
103
                'stdout-only',
846✔
104
                null,
846✔
105
                InputOption::VALUE_NONE,
846✔
106
                "For any errors messages application will use StdOut instead of StdErr. It's on your own risk!",
846✔
107
            )
846✔
108
            ->addOption(
846✔
109
                'non-zero-on-error',
846✔
110
                null,
846✔
111
                InputOption::VALUE_NONE,
846✔
112
                'None-zero exit code on any StdErr message.',
846✔
113
            )
846✔
114
            ->addOption(
846✔
115
                'timestamp',
846✔
116
                null,
846✔
117
                InputOption::VALUE_NONE,
846✔
118
                'Show timestamp at the beginning of each message.'
846✔
119
                . 'It will be used only for <info>' . Text::getName() . '</info> output format.',
846✔
120
            )
846✔
121
            ->addOption(
846✔
122
                'profile',
846✔
123
                null,
846✔
124
                InputOption::VALUE_NONE,
846✔
125
                'Display timing and memory usage information.',
846✔
126
            )
846✔
127
            ->addOption(
846✔
128
                'output-mode',
846✔
129
                null,
846✔
130
                InputOption::VALUE_REQUIRED,
846✔
131
                "Output format. Available options:\n" . CliHelper::renderListForHelpDescription([
846✔
132
                    Text::getName()     => Text::getDescription(),
846✔
133
                    Cron::getName()     => Cron::getDescription(),
846✔
134
                    Logstash::getName() => Logstash::getDescription(),
846✔
135
                ]),
846✔
136
                Text::getName(),
846✔
137
            )
846✔
138
            ->addOption(
846✔
139
                Cron::getName(),
846✔
140
                null,
846✔
141
                InputOption::VALUE_NONE,
846✔
142
                'Alias for <info>--output-mode=' . Cron::getName() . '</info>. <comment>Deprecated!</comment>',
846✔
143
            );
846✔
144

145
        parent::configure();
846✔
146
    }
147

148
    /**
149
     * {@inheritDoc}
150
     */
151
    protected function execute(InputInterface $input, OutputInterface $output): int
152
    {
153
        $this->outputMode = $this->createOutputMode($input, $output, self::getOutputFormat($input));
846✔
154
        $this->getCliApplication()->setOutputMode($this->outputMode);
846✔
155

156
        $exitCode = Codes::OK;
846✔
157

158
        try {
159
            $this->outputMode->onExecBefore();
846✔
160
            $this->trigger('exec.before', [$this, $this->outputMode]);
846✔
161
            \ob_start();
846✔
162
            $exitCode    = $this->executeAction();
846✔
163
            $echoContent = \ob_get_clean();
690✔
164
            if ($echoContent !== false && $echoContent !== '') {
690✔
165
                $this->showLegacyOutput($echoContent);
690✔
166
            }
167
        } catch (\Exception $exception) {
156✔
168
            $echoContent = \ob_get_clean();
156✔
169
            if ($echoContent !== false && $echoContent !== '') {
156✔
170
                $this->showLegacyOutput($echoContent);
90✔
171
            }
172

173
            $this->outputMode->onExecException($exception);
156✔
174
            $this->trigger('exception', [$this, $this->outputMode, $exception]);
156✔
175

176
            $this->outputMode->onExecAfter($exitCode);
156✔
177

178
            if (!$this->getOptBool('mute-errors')) {
156✔
179
                throw $exception;
132✔
180
            }
181
        }
182

183
        $exitCode = Vars::range($exitCode, 0, 255);
714✔
184

185
        if ($this->outputMode->isOutputHasErrors() && $this->getOptBool('non-zero-on-error')) {
714✔
186
            $exitCode = Codes::GENERAL_ERROR;
24✔
187
        }
188

189
        $this->outputMode->onExecAfter($exitCode);
714✔
190
        $this->trigger('exec.after', [$this, $this->outputMode, &$exitCode]);
714✔
191

192
        if ($this->getOptBool('mute-errors')) {
714✔
193
            $exitCode = 0;
24✔
194
        }
195

196
        return $exitCode;
714✔
197
    }
198

199
    /**
200
     * @return null|mixed
201
     */
202
    protected function getOpt(string $optionName, bool $canBeArray = true): mixed
203
    {
204
        $value = $this->outputMode->getInput()->getOption($optionName);
846✔
205

206
        if ($canBeArray && \is_array($value)) {
846✔
207
            return Arr::last($value);
174✔
208
        }
209

210
        return $value;
846✔
211
    }
212

213
    /**
214
     * @SuppressWarnings(PHPMD.BooleanGetMethodName)
215
     */
216
    protected function getOptBool(string $optionName): bool
217
    {
218
        $value = $this->getOpt($optionName);
846✔
219

220
        return bool($value);
846✔
221
    }
222

223
    /**
224
     * @param int[] $onlyExpectedOptions
225
     */
226
    protected function getOptInt(string $optionName, array $onlyExpectedOptions = []): int
227
    {
228
        $value  = $this->getOpt($optionName) ?? 0;
354✔
229
        $result = int($value);
354✔
230

231
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
354✔
232
            throw new Exception(
×
NEW
233
                "Passed invalid value of option \"--{$optionName}={$result}\".\n"
×
NEW
234
                . 'Strict expected int-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
UNCOV
235
            );
×
236
        }
237

238
        return int($value);
354✔
239
    }
240

241
    /**
242
     * @param float[] $onlyExpectedOptions
243
     */
244
    protected function getOptFloat(string $optionName, array $onlyExpectedOptions = []): float
245
    {
246
        $value  = $this->getOpt($optionName) ?? 0.0;
174✔
247
        $result = float($value);
174✔
248

249
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
174✔
250
            throw new Exception(
×
NEW
251
                "Passed invalid value of option \"--{$optionName}={$result}\".\n"
×
NEW
252
                . 'Strict expected float-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
UNCOV
253
            );
×
254
        }
255

256
        return $result;
174✔
257
    }
258

259
    /**
260
     * @param string[] $onlyExpectedOptions
261
     */
262
    protected function getOptString(string $optionName, string $default = '', array $onlyExpectedOptions = []): string
263
    {
264
        $value  = \trim((string)$this->getOpt($optionName));
792✔
265
        $length = \strlen($value);
792✔
266
        $result = $length > 0 ? $value : $default;
792✔
267

268
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
792✔
269
            throw new Exception(
×
NEW
270
                "Passed invalid value of option \"--{$optionName}={$result}\".\n"
×
NEW
271
                . 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
UNCOV
272
            );
×
273
        }
274

275
        return $result;
792✔
276
    }
277

278
    protected function getOptArray(string $optionName): array
279
    {
280
        $list = $this->getOpt($optionName, false) ?? [];
174✔
281

282
        return (array)$list;
174✔
283
    }
284

285
    /**
286
     * @param string[] $onlyExpectedOptions
287
     */
288
    protected function getOptDatetime(
289
        string $optionName,
290
        string $defaultDatetime = '1970-01-01 00:00:00',
291
        array $onlyExpectedOptions = [],
292
    ): \DateTimeImmutable {
293
        $value  = $this->getOptString($optionName);
×
294
        $result = $value === '' ? $defaultDatetime : $value;
×
295

296
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
×
297
            throw new Exception(
×
NEW
298
                "Passed invalid value of option {$optionName}={$result}. "
×
NEW
299
                . 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
UNCOV
300
            );
×
301
        }
302

303
        return new \DateTimeImmutable($result);
×
304
    }
305

306
    /**
307
     * Alias to write new line in std output.
308
     * @SuppressWarnings(PHPMD.CamelCaseMethodName)
309
     */
310
    protected function _(
311
        bool|float|int|iterable|string|null $messages = '',
312
        string $verboseLevel = '',
313
        array $context = [],
314
    ): void {
315
        $this->outputMode->_($messages, $verboseLevel, $context);
606✔
316
    }
317

318
    protected function isInfoLevel(): bool
319
    {
320
        return $this->outputMode->isInfoLevel();
×
321
    }
322

323
    protected function isWarningLevel(): bool
324
    {
325
        return $this->outputMode->isWarningLevel();
×
326
    }
327

328
    protected function isDebugLevel(): bool
329
    {
330
        return $this->outputMode->isDebugLevel();
×
331
    }
332

333
    protected function isProfile(): bool
334
    {
335
        return $this->outputMode->isDisplayProfiling();
×
336
    }
337

338
    /**
339
     * @psalm-suppress PossiblyUnusedReturnValue
340
     */
341
    protected function trigger(string $eventName, array $arguments = [], ?callable $continueCallback = null): int
342
    {
343
        $application = $this->getApplication();
846✔
344
        if ($application === null) {
846✔
345
            return 0;
×
346
        }
347

348
        if ($application instanceof CliApplication) {
846✔
349
            $eManager = $application->getEventManager();
846✔
350
            if ($eManager === null) {
846✔
351
                return 0;
846✔
352
            }
353

354
            return $eManager->trigger("jbzoo.cli.{$eventName}", $arguments, $continueCallback);
×
355
        }
356

357
        return 0;
×
358
    }
359

360
    protected function ask(string $question, string $default = '', bool $isHidden = false): string
361
    {
362
        $question     = \rtrim($question, ':');
×
363
        $questionText = "<yellow-r>Question:</yellow-r> {$question}";
×
364
        if (!$isHidden) {
×
365
            $questionText .= ($default !== '' ? " (Default: <i>{$default}</i>)" : '');
×
366
        }
367

368
        $questionObj = new Question($questionText . ': ', $default);
×
369
        if ($isHidden) {
×
370
            $questionObj->setHidden(true);
×
371
            $questionObj->setHiddenFallback(false);
×
372
        }
373

374
        return (string)$this->getQuestionHelper()->ask(
×
375
            $this->outputMode->getInput(),
×
376
            $this->outputMode->getOutput(),
×
377
            $questionObj,
×
378
        );
×
379
    }
380

381
    protected function askPassword(string $question, bool $randomDefault = true): string
382
    {
383
        $default = '';
×
384
        if ($randomDefault) {
×
385
            $question .= ' (Default: <i>Random</i>)';
×
386
            $default = Str::random(10, false);
×
387
        }
388

389
        return $this->ask($question, $default, true);
×
390
    }
391

392
    protected function confirmation(string $question = 'Are you sure?', bool $default = false): bool
393
    {
394
        $question     = '<yellow-r>Question:</yellow-r> ' . \trim($question);
×
395
        $defaultValue = $default ? 'Y' : 'n';
×
396
        $questionObj  = new ConfirmationQuestion(
×
397
            "{$question} (<c>Y/n</c>; Default: <i>{$defaultValue}</i>): ",
×
398
            $default,
×
399
        );
×
400

401
        return (bool)$this->getQuestionHelper()->ask(
×
402
            $this->outputMode->getInput(),
×
403
            $this->outputMode->getOutput(),
×
404
            $questionObj,
×
405
        );
×
406
    }
407

408
    /**
409
     * @param string[] $options
410
     */
411
    protected function askOption(string $question, array $options, float|int|string|null $default = null): string
412
    {
413
        $question = '<yellow-r>Question:</yellow-r> ' . \trim($question);
×
414

415
        $defaultValue = '';
×
416
        if ($default !== null) {
×
417
            /** @phpstan-ignore-next-line */
418
            $defaultValue = $options[$default] ?? '';
×
419
            if (!bool($default)) {
×
420
                $default = '';
×
421
            }
422

423
            if ($defaultValue !== '') {
×
424
                $defaultValue = " (Default: <i>{$defaultValue}</i>)";
×
425
            }
426
        }
427

428
        $questionObj = new ChoiceQuestion($question . $defaultValue . ': ', $options, $default);
×
429
        $questionObj->setErrorMessage('The option "%s" is undefined. See the avaialable options');
×
430

431
        return (string)$this->getQuestionHelper()->ask(
×
432
            $this->outputMode->getInput(),
×
433
            $this->outputMode->getOutput(),
×
434
            $questionObj,
×
435
        );
×
436
    }
437

438
    /**
439
     * Reads input from STDIN with an optional timeout.
440
     *
441
     * @param  int         $timeout the timeout value in seconds (default: 5)
442
     * @return null|string the string read from STDIN, or null if an error occurred
443
     * @throws Exception   if there was an error reading from STDIN or if the read operation timed out
444
     */
445
    protected static function getStdIn(int $timeout = 5): ?string
446
    {
447
        static $result; // It can be read only once, so we save result as internal variable
24✔
448

449
        if ($result === null) {
24✔
450
            $result = '';
24✔
451

452
            $read        = [\STDIN];
24✔
453
            $write       = [];
24✔
454
            $except      = [];
24✔
455
            $streamCount = \stream_select($read, $write, $except, $timeout);
24✔
456

457
            if ($streamCount > 0) {
24✔
458
                while ($line = \fgets(\STDIN, 1024)) {
24✔
459
                    $result .= $line;
18✔
460
                }
461
            }
462

463
            if ($result === '') {
24✔
464
                cli("Reading from STDIN timed out ({$timeout} seconds)", OutLvl::WARNING);
6✔
465
            }
466
        }
467

468
        return $result;
24✔
469
    }
470

471
    private function showLegacyOutput(string $echoContent): void
472
    {
473
        $lines = \explode("\n", $echoContent);
234✔
474
        $lines = \array_map(static fn ($line) => \rtrim($line), $lines);
234✔
475
        $lines = \array_filter($lines, static fn ($line): bool => $line !== '');
234✔
476

477
        if (\count($lines) > 1) {
234✔
478
            $this->_($lines, OutLvl::LEGACY);
234✔
479
        } else {
480
            $this->_($echoContent, OutLvl::LEGACY);
×
481
        }
482
    }
483

484
    private function getQuestionHelper(): QuestionHelper
485
    {
486
        $helper = $this->getHelper('question');
×
487
        if ($helper instanceof QuestionHelper) {
×
488
            return $helper;
×
489
        }
490

491
        throw new Exception('Symfony QuestionHelper not found');
×
492
    }
493

494
    private function createOutputMode(
495
        InputInterface $input,
496
        OutputInterface $output,
497
        string $outputFormat,
498
    ): AbstractOutputMode {
499
        $application = $this->getCliApplication();
846✔
500

501
        if ($outputFormat === Text::getName()) {
846✔
502
            return new Text($input, $output, $application);
582✔
503
        }
504

505
        if ($outputFormat === Cron::getName()) {
264✔
506
            return new Cron($input, $output, $application);
30✔
507
        }
508

509
        if ($outputFormat === Logstash::getName()) {
234✔
510
            return new Logstash($input, $output, $application);
234✔
511
        }
512

513
        throw new Exception("Unknown output format: {$outputFormat}");
×
514
    }
515

516
    private function getCliApplication(): CliApplication
517
    {
518
        $application = $this->getApplication();
846✔
519
        if ($application === null) {
846✔
520
            throw new Exception('Application not defined. Please, use "setApplication()" method.');
×
521
        }
522

523
        if ($application instanceof CliApplication) {
846✔
524
            return $application;
846✔
525
        }
526

527
        throw new Exception('Application must be instance of "\JBZoo\Cli\CliApplication"');
×
528
    }
529

530
    private static function getOutputFormat(InputInterface $input): string
531
    {
532
        if (bool($input->getOption('cron'))) { // TODO: Must be deprecated in the future
846✔
533
            return Cron::getName();
18✔
534
        }
535

536
        return $input->getOption('output-mode') ?? Text::getName();
828✔
537
    }
538
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc