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

JBZoo / Cli / 5840386379

pending completion
5840386379

push

github

web-flow
Predefined output formats - ELK, cron. New demo and docs. (#15)

745 of 745 new or added lines in 16 files covered. (100.0%)

899 of 1078 relevant lines covered (83.4%)

136.5 hits per line

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

70.32
/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
    public function progressBar(
47
        iterable|int $listOrMax,
48
        \Closure $callback,
49
        string $title = '',
50
        bool $throwBatchException = true,
51
        ?AbstractOutputMode $outputMode = null,
52
    ): AbstractProgressBar {
53
        static $nestedLevel = 0;
84✔
54

55
        $outputMode ??= $this->outputMode;
168✔
56

57
        $progressBar = $outputMode->createProgressBar()
168✔
58
            ->setTitle($title)
168✔
59
            ->setCallback($callback)
168✔
60
            ->setThrowBatchException($throwBatchException);
168✔
61

62
        if (\is_iterable($listOrMax)) {
168✔
63
            $progressBar->setList($listOrMax);
60✔
64
        } else {
65
            $progressBar->setMax($listOrMax);
112✔
66
        }
67

68
        $nestedLevel++;
168✔
69
        $progressBar->setNextedLevel($nestedLevel);
168✔
70

71
        $progressBar->execute();
168✔
72

73
        $nestedLevel--;
132✔
74
        $progressBar->setNextedLevel($nestedLevel);
132✔
75

76
        return $progressBar;
132✔
77
    }
78

79
    /**
80
     * {@inheritDoc}
81
     */
82
    protected function configure(): void
83
    {
84
        $definedShortcuts = !\defined('\JBZOO_CLI_NO_PREDEFINED_SHORTCUTS');
572✔
85

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

144
        parent::configure();
572✔
145
    }
146

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

155
        $exitCode = Codes::OK;
572✔
156

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

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

175
            $this->outputMode->onExecAfter($exitCode);
104✔
176

177
            if (!$this->getOptBool('mute-errors')) {
104✔
178
                throw $exception;
88✔
179
            }
180
        }
181

182
        $exitCode = Vars::range($exitCode, 0, 255);
484✔
183

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

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

191
        if ($this->getOptBool('mute-errors')) {
484✔
192
            $exitCode = 0;
16✔
193
        }
194

195
        return $exitCode;
484✔
196
    }
197

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

205
        if ($canBeArray && \is_array($value)) {
572✔
206
            return Arr::last($value);
116✔
207
        }
208

209
        return $value;
572✔
210
    }
211

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

219
        return bool($value);
572✔
220
    }
221

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

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

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

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

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

255
        return $result;
116✔
256
    }
257

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

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

274
        return $result;
548✔
275
    }
276

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

281
        return (array)$list;
116✔
282
    }
283

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

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

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

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

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

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

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

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

337
    protected function trigger(string $eventName, array $arguments = [], ?callable $continueCallback = null): int
338
    {
339
        $application = $this->getApplication();
572✔
340
        if ($application === null) {
572✔
341
            return 0;
×
342
        }
343

344
        if ($application instanceof CliApplication) {
572✔
345
            $eManager = $application->getEventManager();
572✔
346
            if ($eManager === null) {
572✔
347
                return 0;
572✔
348
            }
349

350
            return $eManager->trigger("jbzoo.cli.{$eventName}", $arguments, $continueCallback);
×
351
        }
352

353
        return 0;
×
354
    }
355

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

364
        $questionObj = new Question($questionText . ': ', $default);
×
365
        if ($isHidden) {
×
366
            $questionObj->setHidden(true);
×
367
            $questionObj->setHiddenFallback(false);
×
368
        }
369

370
        return (string)$this->getQuestionHelper()->ask(
×
371
            $this->outputMode->getInput(),
×
372
            $this->outputMode->getOutput(),
×
373
            $questionObj,
374
        );
375
    }
376

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

385
        return $this->ask($question, $default, true);
×
386
    }
387

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

397
        return (bool)$this->getQuestionHelper()->ask(
×
398
            $this->outputMode->getInput(),
×
399
            $this->outputMode->getOutput(),
×
400
            $questionObj,
401
        );
402
    }
403

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

411
        $defaultValue = '';
×
412
        if ($default !== null) {
×
413
            /** @phpstan-ignore-next-line */
414
            $defaultValue = $options[$default] ?? $default ?: '';
×
415
            if ($defaultValue !== '') {
×
416
                $defaultValue = " (Default: <i>{$defaultValue}</i>)";
×
417
            }
418
        }
419

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

423
        return (string)$this->getQuestionHelper()->ask(
×
424
            $this->outputMode->getInput(),
×
425
            $this->outputMode->getOutput(),
×
426
            $questionObj,
427
        );
428
    }
429

430
    protected static function getStdIn(): ?string
431
    {
432
        static $result; // It can be read only once, so we save result as internal varaible
8✔
433

434
        if ($result === null) {
16✔
435
            $result = '';
16✔
436

437
            while (!\feof(\STDIN)) {
16✔
438
                $result .= \fread(\STDIN, 1024);
16✔
439
            }
440
        }
441

442
        return $result;
16✔
443
    }
444

445
    private function showLegacyOutput(string $echoContent): void
446
    {
447
        $lines = \explode("\n", $echoContent);
168✔
448
        $lines = \array_map(static fn ($line) => \rtrim($line), $lines);
168✔
449
        $lines = \array_filter($lines, static fn ($line): bool => $line !== '');
168✔
450

451
        if (\count($lines) > 1) {
168✔
452
            $this->_($lines, OutLvl::LEGACY);
168✔
453
        } else {
454
            $this->_($echoContent, OutLvl::LEGACY);
×
455
        }
456
    }
457

458
    private function getQuestionHelper(): QuestionHelper
459
    {
460
        $helper = $this->getHelper('question');
×
461
        if ($helper instanceof QuestionHelper) {
×
462
            return $helper;
×
463
        }
464

465
        throw new Exception('Symfony QuestionHelper not found');
×
466
    }
467

468
    private function createOutputMode(
469
        InputInterface $input,
470
        OutputInterface $output,
471
        string $outputFormat,
472
    ): AbstractOutputMode {
473
        $application = $this->getCliApplication();
572✔
474

475
        if ($outputFormat === Text::getName()) {
572✔
476
            return new Text($input, $output, $application);
404✔
477
        }
478

479
        if ($outputFormat === Cron::getName()) {
168✔
480
            return new Cron($input, $output, $application);
16✔
481
        }
482

483
        if ($outputFormat === Logstash::getName()) {
152✔
484
            return new Logstash($input, $output, $application);
152✔
485
        }
486

487
        throw new Exception("Unknown output format: {$outputFormat}");
×
488
    }
489

490
    private function getCliApplication(): CliApplication
491
    {
492
        $application = $this->getApplication();
572✔
493
        if ($application === null) {
572✔
494
            throw new Exception('Application not defined. Please, use "setApplication()" method.');
×
495
        }
496

497
        if ($application instanceof CliApplication) {
572✔
498
            return $application;
572✔
499
        }
500

501
        throw new Exception('Application must be instance of "\JBZoo\Cli\CliApplication"');
×
502
    }
503

504
    private static function getOutputFormat(InputInterface $input): string
505
    {
506
        if (bool($input->getOption('cron'))) { // TODO: Must be deprecated in the future
572✔
507
            return Cron::getName();
12✔
508
        }
509

510
        return $input->getOption('output-mode') ?? Text::getName();
560✔
511
    }
512
}
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

© 2026 Coveralls, Inc