* * 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 */ private array $argDefs = []; /** @var array 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 $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 */ 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 !== ''; } }