76bc91a383
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
All display methods (banner, progress, section, status, log, table, summary box, divider, step) now write to stderr via a new display() helper. This ensures stdout is reserved for machine-readable data, fixing CI pipelines that capture CLI output with $() or redirect to $GITHUB_OUTPUT. Root cause: version_read.php banner was written to stdout, so VERSION=$(php version_read.php --path .) captured the box-drawing characters along with the version string, corrupting $GITHUB_OUTPUT and breaking downstream release steps. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1048 lines
35 KiB
PHP
1048 lines
35 KiB
PHP
<?php
|
|
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* This file is part of a Moko Consulting project.
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: MokoPlatform.Enterprise.CLI
|
|
* INGROUP: MokoPlatform.Enterprise
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /lib/Enterprise/CliFramework.php
|
|
* BRIEF: CliFramework — unified base class for all moko-platform CLI scripts
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MokoEnterprise;
|
|
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
use Exception;
|
|
|
|
// =============================================================================
|
|
// CliFramework — current base class for all moko-platform CLI scripts
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Base class for moko-platform CLI scripts.
|
|
*
|
|
* Provides argument parsing, a structured lifecycle, and a full console
|
|
* graphics system (banners, coloured log levels, progress bars, status
|
|
* lines, section headers, summary boxes) that gracefully degrades when the
|
|
* terminal does not support ANSI escape codes.
|
|
*
|
|
* Lifecycle: configure() -> parseArguments() -> printBanner() -> initialize() -> run()
|
|
*
|
|
* All new scripts must extend CliFramework and implement configure() + run().
|
|
*
|
|
* @since 04.00.15
|
|
* @see CLIApp Legacy base class (deprecated)
|
|
*/
|
|
abstract class CliFramework
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// ANSI colour constants
|
|
// -------------------------------------------------------------------------
|
|
|
|
protected const C_RESET = "\033[0m";
|
|
protected const C_BOLD = "\033[1m";
|
|
protected const C_DIM = "\033[2m";
|
|
protected const C_RED = "\033[31m";
|
|
protected const C_GREEN = "\033[32m";
|
|
protected const C_YELLOW = "\033[33m";
|
|
protected const C_BLUE = "\033[34m";
|
|
protected const C_MAGENTA = "\033[35m";
|
|
protected const C_CYAN = "\033[36m";
|
|
protected const C_GRAY = "\033[90m";
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Unicode graphic characters
|
|
// -------------------------------------------------------------------------
|
|
|
|
protected const ICON_OK = "\u{2713}"; // ✓
|
|
protected const ICON_FAIL = "\u{2717}"; // ✗
|
|
protected const ICON_WARN = "\u{26A0}"; // ⚠
|
|
protected const ICON_INFO = "\u{2192}"; // →
|
|
protected const ICON_DRY = "\u{25CC}"; // ◌
|
|
|
|
protected const BOX_H = "\u{2500}"; // ─
|
|
protected const BOX_V = "\u{2502}"; // │
|
|
protected const BOX_TL = "\u{250C}"; // ┌
|
|
protected const BOX_TR = "\u{2510}"; // ┐
|
|
protected const BOX_BL = "\u{2514}"; // └
|
|
protected const BOX_BR = "\u{2518}"; // ┘
|
|
|
|
protected const BAR_FILL = "\u{2588}"; // █
|
|
protected const BAR_EMPTY = "\u{2591}"; // ░
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Standard exit codes (#237)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** Process completed successfully. */
|
|
public const EXIT_SUCCESS = 0;
|
|
|
|
/** General failure (assertion, business logic, validation). */
|
|
public const EXIT_FAILURE = 1;
|
|
|
|
/** Usage / argument error (wrong flags, missing required args). */
|
|
public const EXIT_USAGE = 2;
|
|
|
|
/** Resource not found (file, repo, API object). */
|
|
public const EXIT_NOT_FOUND = 3;
|
|
|
|
/** Permission or authentication error. */
|
|
public const EXIT_PERMISSION = 4;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Script properties (set by configure())
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** @var string One-line description shown in the banner. */
|
|
private string $description = '';
|
|
|
|
/** @var string Script name. */
|
|
private string $scriptName = '';
|
|
|
|
/** @var string Script version shown in the banner. */
|
|
private string $scriptVersion = '04.00.15';
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Argument definitions registered via addArgument()
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** @var array<string, array{desc: string, default: mixed}> */
|
|
private array $argDefs = [];
|
|
|
|
/** @var array<string, mixed> Parsed argument values. */
|
|
private array $parsedArgs = [];
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Runtime flags (set from CLI arguments)
|
|
// -------------------------------------------------------------------------
|
|
|
|
protected bool $quiet = false;
|
|
protected bool $verbose = false;
|
|
protected bool $dryRun = false;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal state
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** @var bool|null Cached terminal-colour detection result. */
|
|
private ?bool $colorEnabled = null;
|
|
|
|
/** @var bool Whether a progress bar is currently active (needs clearing). */
|
|
private bool $progressActive = false;
|
|
|
|
/** @var float Script start time for elapsed-time reporting. */
|
|
private float $startTime;
|
|
|
|
// =========================================================================
|
|
// Display output — all decorative output goes to stderr
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Write decorative/diagnostic output to stderr.
|
|
*
|
|
* All non-data output (banners, progress bars, section headers, status
|
|
* lines, log messages) MUST use this method so that stdout is reserved
|
|
* for machine-readable data. This ensures that shell captures like
|
|
* VERSION=$(php version_read.php --path .)
|
|
* only receive the actual data, not decorative text.
|
|
*
|
|
* @since 04.00.16
|
|
*/
|
|
protected function display(string $text): void
|
|
{
|
|
fwrite(STDERR, $text);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Constructor
|
|
// =========================================================================
|
|
|
|
/**
|
|
* @param string $name Script name (e.g. 'check_changelog').
|
|
* @param string $version Script version string.
|
|
*/
|
|
public function __construct(string $name = '', string $version = '04.00.15')
|
|
{
|
|
$this->scriptName = $name ?: basename($_SERVER['argv'][0] ?? 'script', '.php');
|
|
$this->scriptVersion = $version;
|
|
$this->startTime = microtime(true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Abstract methods — implement in each script
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Register arguments and set the description.
|
|
* Called automatically by execute() before argument parsing.
|
|
*/
|
|
abstract protected function configure(): void;
|
|
|
|
/**
|
|
* Main script logic.
|
|
*
|
|
* @return int Exit code: 0 = success, 1 = failure, 2 = misuse.
|
|
*/
|
|
abstract protected function run(): int;
|
|
|
|
// =========================================================================
|
|
// Optional override
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Post-parse initialisation hook.
|
|
* Override to set up services after arguments are available.
|
|
*/
|
|
protected function initialize(): void
|
|
{
|
|
}
|
|
|
|
// =========================================================================
|
|
// Lifecycle
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Run the script: configure -> parse -> banner -> initialize -> run.
|
|
*
|
|
* @return int Exit code.
|
|
*/
|
|
public function execute(): int
|
|
{
|
|
$this->configure();
|
|
$this->parseArguments();
|
|
|
|
if ($this->hasRawArg('--help') || $this->hasRawArg('-h')) {
|
|
$this->printHelp();
|
|
return 0;
|
|
}
|
|
|
|
$this->quiet = $this->hasRawArg('--quiet') || $this->hasRawArg('-q');
|
|
$this->verbose = $this->hasRawArg('--verbose') || $this->hasRawArg('-v');
|
|
$this->dryRun = $this->hasRawArg('--dry-run');
|
|
|
|
if (!$this->quiet) {
|
|
$this->printBanner();
|
|
}
|
|
|
|
if ($this->dryRun && !$this->quiet) {
|
|
$this->printDryRunNotice();
|
|
}
|
|
|
|
$this->initialize();
|
|
|
|
try {
|
|
$code = $this->run();
|
|
} catch (\Exception $e) {
|
|
$this->clearProgress();
|
|
$this->log('ERROR', $e->getMessage());
|
|
return 1;
|
|
}
|
|
|
|
return $code;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Argument registration
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Set the one-line description shown in the banner and help.
|
|
*/
|
|
protected function setDescription(string $desc): void
|
|
{
|
|
$this->description = $desc;
|
|
}
|
|
|
|
/**
|
|
* Register an argument.
|
|
*
|
|
* @param string $name Argument name with dashes, e.g. '--path'.
|
|
* @param string $desc Short description for the help screen.
|
|
* @param mixed $default Default value; pass false for boolean flags.
|
|
*/
|
|
protected function addArgument(string $name, string $desc, mixed $default = null): void
|
|
{
|
|
$this->argDefs[$name] = ['desc' => $desc, 'default' => $default];
|
|
}
|
|
|
|
/**
|
|
* Get a parsed argument value.
|
|
*
|
|
* @param string $name Argument name, e.g. '--path'.
|
|
* @param mixed $fallback Override the registered default for this call.
|
|
* @return mixed
|
|
*/
|
|
protected function getArgument(string $name, mixed $fallback = null): mixed
|
|
{
|
|
if (array_key_exists($name, $this->parsedArgs)) {
|
|
return $this->parsedArgs[$name];
|
|
}
|
|
if ($fallback !== null) {
|
|
return $fallback;
|
|
}
|
|
return $this->argDefs[$name]['default'] ?? null;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Argument parsing (internal)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Parse CLI arguments from $_SERVER['argv'] into registered argument definitions.
|
|
*
|
|
* @since 04.00.15
|
|
*/
|
|
private function parseArguments(): void
|
|
{
|
|
$argv = array_slice($_SERVER['argv'] ?? [], 1);
|
|
$len = count($argv);
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$token = $argv[$i];
|
|
if (!str_starts_with($token, '-')) {
|
|
continue;
|
|
}
|
|
if (str_contains($token, '=')) {
|
|
[$key, $val] = explode('=', $token, 2);
|
|
$this->parsedArgs[$key] = $val;
|
|
} elseif (
|
|
isset($argv[$i + 1])
|
|
&& !str_starts_with($argv[$i + 1], '-')
|
|
&& isset($this->argDefs[$token])
|
|
&& $this->argDefs[$token]['default'] !== false
|
|
) {
|
|
$this->parsedArgs[$token] = $argv[$i + 1];
|
|
$i++;
|
|
} else {
|
|
$this->parsedArgs[$token] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Check if a raw flag was passed on the command line. */
|
|
private function hasRawArg(string $flag): bool
|
|
{
|
|
return in_array($flag, $_SERVER['argv'] ?? [], true)
|
|
|| array_key_exists($flag, $this->parsedArgs);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Help screen
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Print auto-generated help screen from registered arguments.
|
|
*
|
|
* @since 04.00.15
|
|
*/
|
|
protected function printHelp(): void
|
|
{
|
|
$w = $this->termWidth();
|
|
$this->display($this->c(self::C_BOLD . self::C_CYAN, $this->scriptName));
|
|
if ($this->description !== '') {
|
|
$this->display(' — ' . $this->description);
|
|
}
|
|
$this->display("\n");
|
|
$this->display($this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n");
|
|
$this->display($this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n");
|
|
$this->display($this->c(self::C_BOLD, 'Options:') . "\n");
|
|
|
|
$builtIn = [
|
|
'--help' => ['desc' => 'Show this help message', 'default' => null],
|
|
'--dry-run' => ['desc' => 'Preview changes without writing', 'default' => null],
|
|
'--verbose' => ['desc' => 'Show detailed output', 'default' => null],
|
|
'--quiet' => ['desc' => 'Suppress all non-error output', 'default' => null],
|
|
'--no-color' => ['desc' => 'Disable ANSI colour output', 'default' => null],
|
|
];
|
|
|
|
foreach (array_merge($this->argDefs, $builtIn) as $name => $def) {
|
|
$default = $def['default'];
|
|
$hint = ($default !== null && $default !== false)
|
|
? $this->c(self::C_DIM, " (default: {$default})")
|
|
: '';
|
|
$this->display(sprintf(
|
|
" %s%-22s%s%s%s\n",
|
|
self::C_CYAN,
|
|
$name,
|
|
self::C_RESET,
|
|
$def['desc'],
|
|
$hint
|
|
));
|
|
}
|
|
$this->display("\n");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — banner
|
|
// =========================================================================
|
|
|
|
/** Print the script header banner with name, description, and version. */
|
|
protected function printBanner(): void
|
|
{
|
|
$w = min($this->termWidth(), 70);
|
|
$inner = $w - 2;
|
|
$name = $this->scriptName;
|
|
$ver = 'v' . $this->scriptVersion;
|
|
$desc = $this->description;
|
|
|
|
$titleRaw = " {$name} {$ver}";
|
|
$titleStyled = " {$name} " . $this->c(self::C_DIM, $ver);
|
|
$titleLine = $this->padRight($titleStyled, $inner, strlen($titleRaw));
|
|
$descLine = ($desc !== '') ? $this->padRight(" {$desc}", $inner) : null;
|
|
|
|
$this->display("\n");
|
|
$this->display($this->c(
|
|
self::C_CYAN,
|
|
self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR
|
|
) . "\n");
|
|
$this->display($this->c(self::C_CYAN, self::BOX_V)
|
|
. $this->c(self::C_BOLD, $titleLine)
|
|
. $this->c(self::C_CYAN, self::BOX_V) . "\n");
|
|
if ($descLine !== null) {
|
|
$this->display($this->c(self::C_CYAN, self::BOX_V)
|
|
. $this->c(self::C_DIM, $descLine)
|
|
. $this->c(self::C_CYAN, self::BOX_V) . "\n");
|
|
}
|
|
$this->display($this->c(
|
|
self::C_CYAN,
|
|
self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR
|
|
) . "\n\n");
|
|
}
|
|
|
|
/** Print the dry-run notice box. */
|
|
protected function printDryRunNotice(): void
|
|
{
|
|
$w = min($this->termWidth(), 70);
|
|
$msg = ' ' . self::ICON_DRY . ' DRY-RUN MODE — no changes will be written ';
|
|
$row = $this->padRight($msg, $w - 2);
|
|
$this->display($this->c(
|
|
self::C_YELLOW . self::C_BOLD,
|
|
self::BOX_TL . str_repeat(self::BOX_H, $w - 2) . self::BOX_TR
|
|
) . "\n");
|
|
$this->display($this->c(
|
|
self::C_YELLOW . self::C_BOLD,
|
|
self::BOX_V . $row . self::BOX_V
|
|
) . "\n");
|
|
$this->display($this->c(
|
|
self::C_YELLOW . self::C_BOLD,
|
|
self::BOX_BL . str_repeat(self::BOX_H, $w - 2) . self::BOX_BR
|
|
) . "\n\n");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — sections and dividers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Print a section header line.
|
|
*
|
|
* Output example: ── Scanning Files ─────────────────────────────
|
|
*/
|
|
protected function section(string $title): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
$this->clearProgress();
|
|
$w = $this->termWidth();
|
|
$text = " {$title} ";
|
|
$fill = max(0, $w - strlen($text) - 4);
|
|
$this->display("\n");
|
|
$this->display($this->c(
|
|
self::C_CYAN,
|
|
str_repeat(self::BOX_H, 2) . $text . str_repeat(self::BOX_H, $fill)
|
|
) . "\n\n");
|
|
}
|
|
|
|
/** Print a plain horizontal divider. */
|
|
protected function printDivider(): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
$this->clearProgress();
|
|
$this->display($this->c(self::C_DIM, str_repeat(self::BOX_H, $this->termWidth())) . "\n");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — logging
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Log a message with a level badge and timestamp.
|
|
*
|
|
* Two calling conventions:
|
|
* log('INFO', 'message') — explicit level
|
|
* log('message') — defaults to INFO
|
|
*
|
|
* @param string $levelOrMessage Level (INFO|SUCCESS|WARNING|ERROR|DEBUG) or message.
|
|
* @param string $message Message text when first arg is a level name.
|
|
*/
|
|
protected function log(string $levelOrMessage, string $message = ''): void
|
|
{
|
|
if ($message === '') {
|
|
$level = 'INFO';
|
|
$text = $levelOrMessage;
|
|
} else {
|
|
$level = strtoupper($levelOrMessage);
|
|
$text = $message;
|
|
}
|
|
|
|
if ($this->quiet && !in_array($level, ['ERROR', 'WARNING'], true)) {
|
|
return;
|
|
}
|
|
if (!$this->verbose && $level === 'DEBUG') {
|
|
return;
|
|
}
|
|
|
|
$this->clearProgress();
|
|
[$icon, $color] = $this->levelStyle($level);
|
|
|
|
$badge = $this->c($color . self::C_BOLD, sprintf('[%-7s]', $level));
|
|
$icon = $this->c($color, $icon);
|
|
$ts = $this->c(
|
|
self::C_GRAY,
|
|
(new \DateTime('now', new \DateTimeZone('UTC')))->format('H:i:s')
|
|
);
|
|
|
|
$line = "{$ts} {$icon} {$badge} {$text}\n";
|
|
|
|
$this->display($line);
|
|
}
|
|
|
|
/** Log a success message. */
|
|
protected function success(string $message): void
|
|
{
|
|
$this->log('SUCCESS', $message);
|
|
}
|
|
|
|
/** Log a warning message. */
|
|
protected function warning(string $message): void
|
|
{
|
|
$this->log('WARNING', $message);
|
|
}
|
|
|
|
/** Alias for warning(). */
|
|
protected function warn(string $message): void
|
|
{
|
|
$this->warning($message);
|
|
}
|
|
|
|
/**
|
|
* Log an error message and exit.
|
|
*
|
|
* @param string $message Error description.
|
|
* @param int $exitCode Process exit code.
|
|
* @return never
|
|
*/
|
|
protected function error(string $message, int $exitCode = 1): never
|
|
{
|
|
$this->clearProgress();
|
|
$this->log('ERROR', $message);
|
|
exit($exitCode);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — status lines (individual check results)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Print a single check-result status line.
|
|
*
|
|
* Output examples:
|
|
* ✓ CHANGELOG.md exists
|
|
* ✗ README.md missing — expected at repo root
|
|
*
|
|
* @param bool $passed Whether the check passed.
|
|
* @param string $label Check description.
|
|
* @param string $detail Optional detail shown in dim text.
|
|
*/
|
|
protected function status(bool $passed, string $label, string $detail = ''): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
$this->clearProgress();
|
|
|
|
[$icon, $color] = $passed
|
|
? [self::ICON_OK, self::C_GREEN]
|
|
: [self::ICON_FAIL, self::C_RED];
|
|
|
|
$suffix = ($detail !== '')
|
|
? ' ' . $this->c(self::C_DIM, "— {$detail}")
|
|
: '';
|
|
|
|
$this->display(' ' . $this->c($color . self::C_BOLD, $icon) . ' ' . $label . $suffix . "\n");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — progress bar
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Render an in-place progress bar (overwrites the current terminal line).
|
|
*
|
|
* Call with $newline = true on the final item to finalise the bar.
|
|
*
|
|
* @param int $current Items processed so far.
|
|
* @param int $total Total items.
|
|
* @param string $label Optional label shown after the bar.
|
|
* @param bool $newline Finalise the bar with a newline.
|
|
*/
|
|
protected function progress(int $current, int $total, string $label = '', bool $newline = false): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
|
|
$barWidth = min(30, $this->termWidth() - 22);
|
|
$filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0;
|
|
$pct = ($total > 0) ? (int) round(100 * $current / $total) : 0;
|
|
|
|
$bar = $this->c(self::C_GREEN, str_repeat(self::BAR_FILL, $filled))
|
|
. $this->c(self::C_DIM, str_repeat(self::BAR_EMPTY, $barWidth - $filled));
|
|
|
|
$counter = $this->c(self::C_DIM, "({$current}/{$total})");
|
|
$percent = $this->c(self::C_BOLD, sprintf('%3d%%', $pct));
|
|
$suffix = ($label !== '') ? " {$label}" : '';
|
|
|
|
$line = " [{$bar}] {$percent} {$counter}{$suffix}";
|
|
|
|
if ($newline) {
|
|
$this->display("\r{$line}\n");
|
|
$this->progressActive = false;
|
|
} else {
|
|
$this->display("\r{$line}");
|
|
$this->progressActive = true;
|
|
}
|
|
}
|
|
|
|
/** Clear the active progress bar line. */
|
|
protected function clearProgress(): void
|
|
{
|
|
if ($this->progressActive) {
|
|
$this->display("\r" . str_repeat(' ', $this->termWidth()) . "\r");
|
|
$this->progressActive = false;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — summary box
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Print a bordered summary box from a label => value map.
|
|
*
|
|
* @param array<string, string|int|float> $rows Label => value pairs.
|
|
* @param bool|null $passed Colours the border green/red/neutral.
|
|
*/
|
|
protected function printSummaryBox(array $rows, ?bool $passed = null): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
$this->clearProgress();
|
|
|
|
$color = match ($passed) {
|
|
true => self::C_GREEN,
|
|
false => self::C_RED,
|
|
default => self::C_CYAN,
|
|
};
|
|
|
|
$maxKey = max(array_map('strlen', array_keys($rows)));
|
|
$inner = $maxKey + 20;
|
|
|
|
$this->display("\n");
|
|
$this->display($this->c($color, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n");
|
|
|
|
foreach ($rows as $label => $value) {
|
|
$valStr = (string) $value;
|
|
$valVis = strlen((string) preg_replace('/\033\[[0-9;]*m/', '', $valStr));
|
|
$padding = $inner - strlen($label) - $valVis - 4;
|
|
$row = ' ' . $this->c(self::C_BOLD, $label)
|
|
. str_repeat(' ', max(1, $padding)) . $valStr . ' ';
|
|
$this->display($this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n");
|
|
}
|
|
|
|
$this->display($this->c($color, self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n");
|
|
}
|
|
|
|
/**
|
|
* Print a standardised pass/fail summary.
|
|
*
|
|
* @param int $passed Checks that passed.
|
|
* @param int $failed Checks that failed.
|
|
* @param float $elapsed Elapsed seconds (omit row when 0).
|
|
*/
|
|
protected function printSummary(int $passed, int $failed, float $elapsed = 0.0): void
|
|
{
|
|
$total = $passed + $failed;
|
|
$score = ($total > 0) ? (int) round(100 * $passed / $total) : 0;
|
|
$ok = $failed === 0;
|
|
|
|
$rows = [
|
|
'Checks' => $total,
|
|
'Passed' => $passed . ' ' . $this->c(self::C_GREEN, self::ICON_OK),
|
|
'Failed' => $failed . ($failed > 0 ? ' ' . $this->c(self::C_RED, self::ICON_FAIL) : ''),
|
|
'Score' => "{$score}%",
|
|
];
|
|
if ($elapsed > 0.0) {
|
|
$rows['Elapsed'] = sprintf('%.2fs', $elapsed);
|
|
}
|
|
|
|
$this->printSummaryBox($rows, $ok);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Console graphics — step indicator
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Print a numbered step header.
|
|
*
|
|
* Output example: Step 2/5 → Running security checks
|
|
*/
|
|
protected function step(int $current, int $total, string $title): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
$this->clearProgress();
|
|
$badge = $this->c(self::C_BOLD . self::C_MAGENTA, "Step {$current}/{$total}");
|
|
$arrow = $this->c(self::C_DIM, self::ICON_INFO);
|
|
$this->display("\n{$badge} {$arrow} {$title}\n");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Colour helpers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Wrap $text in ANSI codes; returns plain $text when colour is disabled.
|
|
*
|
|
* When called with only $codes (no $text), returns the raw ANSI string
|
|
* for use in string concatenation.
|
|
*
|
|
* @param string $codes Concatenated ANSI escape sequences.
|
|
* @param string $text Text to wrap (optional).
|
|
*/
|
|
protected function c(string $codes, string $text = ''): string
|
|
{
|
|
if (!$this->isColorEnabled()) {
|
|
return $text;
|
|
}
|
|
if ($text === '') {
|
|
return $codes;
|
|
}
|
|
return $codes . $text . self::C_RESET;
|
|
}
|
|
|
|
/**
|
|
* Return whether ANSI colour output is enabled.
|
|
*
|
|
* Disabled when --no-color is passed, NO_COLOR env var is set
|
|
* (https://no-color.org), or stdout is not an interactive terminal.
|
|
*/
|
|
protected function isColorEnabled(): bool
|
|
{
|
|
if ($this->colorEnabled !== null) {
|
|
return $this->colorEnabled;
|
|
}
|
|
if ($this->hasRawArg('--no-color') || getenv('NO_COLOR') !== false) {
|
|
return $this->colorEnabled = false;
|
|
}
|
|
return $this->colorEnabled = stream_isatty(STDOUT);
|
|
}
|
|
|
|
/**
|
|
* Return the terminal width (defaults to 80 when not detectable).
|
|
*/
|
|
protected function termWidth(): int
|
|
{
|
|
$cols = (int) getenv('COLUMNS');
|
|
return ($cols > 40) ? $cols : 80;
|
|
}
|
|
|
|
/**
|
|
* Return elapsed seconds since the script started.
|
|
*/
|
|
protected function elapsed(): float
|
|
{
|
|
return microtime(true) - $this->startTime;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Internal helpers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Return [icon, ANSI colour code] for a given log level.
|
|
*
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
private function levelStyle(string $level): array
|
|
{
|
|
return match ($level) {
|
|
'SUCCESS' => [self::ICON_OK, self::C_GREEN],
|
|
'ERROR' => [self::ICON_FAIL, self::C_RED],
|
|
'WARNING' => [self::ICON_WARN, self::C_YELLOW],
|
|
'DEBUG' => ['.', self::C_DIM],
|
|
default => [self::ICON_INFO, self::C_CYAN],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Right-pad a string to the given visible width, ignoring ANSI codes.
|
|
*
|
|
* @param string $text Text to pad (may contain ANSI codes).
|
|
* @param int $width Target visible width.
|
|
* @param int $visibleLength Override auto-detected visible length.
|
|
*/
|
|
private function padRight(string $text, int $width, int $visibleLength = -1): string
|
|
{
|
|
if ($visibleLength < 0) {
|
|
$stripped = (string) preg_replace('/\033\[[0-9;]*m/', '', $text);
|
|
$visibleLength = strlen($stripped);
|
|
}
|
|
return $text . str_repeat(' ', max(0, $width - $visibleLength));
|
|
}
|
|
|
|
// =========================================================================
|
|
// JSON output (#241) — standard envelope for --json mode
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Whether --json mode is active.
|
|
*/
|
|
protected function isJsonMode(): bool
|
|
{
|
|
return $this->hasRawArg('--json');
|
|
}
|
|
|
|
/**
|
|
* Emit a standardised JSON envelope and exit.
|
|
*
|
|
* Envelope format:
|
|
* {
|
|
* "command": "check:syntax",
|
|
* "status": "pass|fail|error",
|
|
* "exit_code": 0,
|
|
* "data": { ... },
|
|
* "errors": [],
|
|
* "warnings": [],
|
|
* "metadata": { "duration_ms": 123, "timestamp": "..." }
|
|
* }
|
|
*
|
|
* @param string $status One of "pass", "fail", "error".
|
|
* @param mixed $data Arbitrary payload.
|
|
* @param string[] $errors Error messages.
|
|
* @param string[] $warnings Warning messages.
|
|
* @param int $exitCode Process exit code.
|
|
* @return int The exit code (for use with `return $this->jsonOutput(...)`).
|
|
*/
|
|
protected function jsonOutput(
|
|
string $status,
|
|
mixed $data = null,
|
|
array $errors = [],
|
|
array $warnings = [],
|
|
int $exitCode = -1
|
|
): int {
|
|
if ($exitCode < 0) {
|
|
$exitCode = ($status === 'pass') ? self::EXIT_SUCCESS : self::EXIT_FAILURE;
|
|
}
|
|
|
|
$envelope = [
|
|
'command' => $this->scriptName,
|
|
'status' => $status,
|
|
'exit_code' => $exitCode,
|
|
'data' => $data,
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
'metadata' => [
|
|
'duration_ms' => (int) round($this->elapsed() * 1000),
|
|
'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
|
|
],
|
|
];
|
|
|
|
echo json_encode($envelope, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
|
return $exitCode;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Interactive prompts (#240)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Ask a yes/no confirmation question.
|
|
*
|
|
* Returns the default value when stdin is not interactive.
|
|
*
|
|
* @param string $message Question to display.
|
|
* @param bool $default Default answer when user presses Enter.
|
|
*/
|
|
protected function confirm(string $message, bool $default = false): bool
|
|
{
|
|
if (!stream_isatty(STDIN)) {
|
|
return $default;
|
|
}
|
|
|
|
$hint = $default ? '[Y/n]' : '[y/N]';
|
|
fwrite(STDOUT, "{$message} {$hint}: ");
|
|
$answer = strtolower(trim((string) fgets(STDIN)));
|
|
|
|
if ($answer === '') {
|
|
return $default;
|
|
}
|
|
return in_array($answer, ['y', 'yes'], true);
|
|
}
|
|
|
|
/**
|
|
* Prompt the user for free-text input.
|
|
*
|
|
* Returns the default value when stdin is not interactive (piped mode).
|
|
*
|
|
* @param string $prompt Question to display.
|
|
* @param string $default Default value shown in brackets.
|
|
*/
|
|
protected function input(string $prompt, string $default = ''): string
|
|
{
|
|
if (!stream_isatty(STDIN)) {
|
|
return $default;
|
|
}
|
|
|
|
$hint = $default !== '' ? " [{$default}]" : '';
|
|
fwrite(STDOUT, "{$prompt}{$hint}: ");
|
|
$line = trim((string) fgets(STDIN));
|
|
return $line !== '' ? $line : $default;
|
|
}
|
|
|
|
/**
|
|
* Present a numbered list and return the selected option.
|
|
*
|
|
* Returns the first option when stdin is not interactive.
|
|
*
|
|
* @param string $prompt Question to display.
|
|
* @param string[] $options List of choices.
|
|
* @return string The chosen option value.
|
|
*/
|
|
protected function select(string $prompt, array $options): string
|
|
{
|
|
if (empty($options)) {
|
|
return '';
|
|
}
|
|
if (!stream_isatty(STDIN)) {
|
|
return $options[0];
|
|
}
|
|
|
|
echo "{$prompt}\n";
|
|
foreach ($options as $i => $opt) {
|
|
printf(" %s%d%s) %s\n", self::C_CYAN, $i + 1, self::C_RESET, $opt);
|
|
}
|
|
fwrite(STDOUT, "Choice [1]: ");
|
|
$choice = trim((string) fgets(STDIN));
|
|
$index = ($choice !== '' ? (int) $choice : 1) - 1;
|
|
|
|
return $options[$index] ?? $options[0];
|
|
}
|
|
|
|
/**
|
|
* Render a simple ASCII table.
|
|
*
|
|
* @param string[] $headers Column headers.
|
|
* @param string[][] $rows Row data (each row is an array of cell strings).
|
|
*/
|
|
protected function table(array $headers, array $rows): void
|
|
{
|
|
if ($this->quiet) {
|
|
return;
|
|
}
|
|
|
|
// Calculate column widths.
|
|
$widths = array_map('strlen', $headers);
|
|
foreach ($rows as $row) {
|
|
foreach ($row as $i => $cell) {
|
|
$widths[$i] = max($widths[$i] ?? 0, strlen((string) $cell));
|
|
}
|
|
}
|
|
|
|
// Build separator line.
|
|
$sep = '+';
|
|
foreach ($widths as $w) {
|
|
$sep .= str_repeat('-', $w + 2) . '+';
|
|
}
|
|
|
|
// Header.
|
|
$this->display($sep . "\n");
|
|
$headerLine = '|';
|
|
foreach ($headers as $i => $h) {
|
|
$headerLine .= ' ' . $this->c(self::C_BOLD, str_pad($h, $widths[$i])) . ' |';
|
|
}
|
|
$this->display($headerLine . "\n");
|
|
$this->display($sep . "\n");
|
|
|
|
// Rows.
|
|
foreach ($rows as $row) {
|
|
$line = '|';
|
|
foreach ($row as $i => $cell) {
|
|
$line .= ' ' . str_pad((string) $cell, $widths[$i]) . ' |';
|
|
}
|
|
$this->display($line . "\n");
|
|
}
|
|
$this->display($sep . "\n");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CLIApp — deprecated compatibility shim
|
|
// =============================================================================
|
|
|
|
/**
|
|
* @deprecated CLIApp has been consolidated into CliFramework. Use CliFramework instead.
|
|
* This stub prevents fatal errors if client repos still reference CLIApp.
|
|
*/
|
|
abstract class CLIApp extends CliFramework
|
|
{
|
|
public function __construct(string $name = '', string $description = '', string $version = '04.06.00')
|
|
{
|
|
parent::__construct($name, $version);
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
// CLIApp subclasses use setupArguments() — bridge it.
|
|
$args = $this->setupArguments();
|
|
foreach ($args as $spec => $desc) {
|
|
$name = '--' . rtrim($spec, ':');
|
|
$default = str_ends_with($spec, ':') ? '' : false;
|
|
$this->addArgument($name, $desc, $default);
|
|
}
|
|
}
|
|
|
|
/** @return array<string,string> */
|
|
protected function setupArguments(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/** Bridge: CLIApp used getOption(), CliFramework uses getArgument(). */
|
|
protected function getOption(string $name, mixed $default = null): mixed
|
|
{
|
|
return $this->getArgument('--' . $name, $default);
|
|
}
|
|
|
|
/** Bridge: CLIApp used hasOption(). */
|
|
protected function hasOption(string $name): bool
|
|
{
|
|
$val = $this->getArgument('--' . $name);
|
|
return $val !== null && $val !== false && $val !== '';
|
|
}
|
|
}
|