* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Enterprise.CLI * INGROUP: MokoStandards.Enterprise * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/Enterprise/CliFramework.php * BRIEF: CLI base classes — CliFramework (current) and CLIApp (legacy) * NOTE: All new scripts must extend CliFramework, not CLIApp. * CLIApp remains for backward-compatibility with existing scripts. */ declare(strict_types=1); namespace MokoEnterprise; use DateTime; use DateTimeZone; use Exception; /** * Base class for CLI applications with common functionality */ abstract class CLIApp { private const VERSION = '04.06.00'; protected string $name; protected string $description; protected string $version; protected array $options = []; protected array $arguments = []; protected bool $verbose = false; protected bool $quiet = false; protected bool $dryRun = false; protected bool $jsonOutput = false; // Enterprise features protected ?MetricsCollector $metrics = null; protected ?object $auditLogger = null; public function __construct(string $name, string $description = '', string $version = self::VERSION) { $this->name = $name; $this->description = $description ?: "{$name} - MokoStandards CLI Tool"; $this->version = $version; } /** * Setup script-specific arguments * * Return an associative array where keys are option specs and values are descriptions. * Option spec format: 'name:' for required value, 'name::' for optional value, 'name' for flag * * @return array Option specifications and descriptions */ abstract protected function setupArguments(): array; /** * Main execution logic * * @return int Exit code (0 for success, non-zero for error) */ abstract protected function run(): int; /** * Get common CLI options * * @return array Common options */ protected function getCommonOptions(): array { return [ 'version' => 'Display version information', 'verbose' => 'Enable verbose output', 'v' => 'Alias for --verbose', 'quiet' => 'Suppress non-error output', 'q' => 'Alias for --quiet', 'dry-run' => 'Perform dry run without making changes', 'json' => 'Output results in JSON format', 'metrics' => 'Collect and display metrics', 'help' => 'Display this help message', 'h' => 'Alias for --help', ]; } /** * Parse command line arguments */ protected function parseArguments(): void { $shortOpts = 'vqh'; $longOpts = [ 'version', 'verbose', 'quiet', 'dry-run', 'json', 'metrics', 'help', ]; // Add custom options $customOpts = $this->setupArguments(); foreach (array_keys($customOpts) as $opt) { if (str_ends_with($opt, '::')) { $longOpts[] = rtrim($opt, ':') . '::'; } elseif (str_ends_with($opt, ':')) { $longOpts[] = rtrim($opt, ':') . ':'; } else { $longOpts[] = $opt; } } // Use $GLOBALS['argv'] instead of getopt() so that bin/moko's in-process // dispatch (via require) is respected. PHP's getopt() reads from the C-level // argv set at process start and ignores runtime changes to $argv/$_SERVER['argv']. $this->options = $this->parseArgv($GLOBALS['argv'] ?? [], $longOpts, $shortOpts); $this->arguments = array_slice($GLOBALS['argv'] ?? [], 1); // Handle common flags if (isset($this->options['version'])) { echo "{$this->name} v{$this->version}\n"; exit(0); } if (isset($this->options['help']) || isset($this->options['h'])) { $this->printHelp(); exit(0); } $this->verbose = isset($this->options['verbose']) || isset($this->options['v']); $this->quiet = isset($this->options['quiet']) || isset($this->options['q']); $this->dryRun = isset($this->options['dry-run']); $this->jsonOutput = isset($this->options['json']); } /** * Parse an argv array using getopt-compatible option specs. * * Replaces PHP's getopt() so callers can inject a custom argv (e.g. bin/moko * in-process dispatch sets $GLOBALS['argv'] before require-ing a script). * * @param list $argv Full argv including $argv[0] (script path) * @param list $longOpts Long option specs, e.g. ['verbose', 'repos:', 'path::'] * @param string $shortOpts Short option chars, e.g. 'vqh' * @return array> Parsed options (same shape as getopt()) */ private function parseArgv(array $argv, array $longOpts, string $shortOpts): array { $result = []; $tokens = array_slice($argv, 1); // Build lookup: option-name → 0=flag, 1=required-value, 2=optional-value $specs = []; foreach ($longOpts as $spec) { if (str_ends_with($spec, '::')) { $specs[rtrim($spec, ':')] = 2; } elseif (str_ends_with($spec, ':')) { $specs[rtrim($spec, ':')] = 1; } else { $specs[$spec] = 0; } } for ($i = 0, $len = strlen($shortOpts); $i < $len; $i++) { $c = $shortOpts[$i]; if ($c === ':') { continue; } $mode = 0; if (isset($shortOpts[$i + 1]) && $shortOpts[$i + 1] === ':') { $mode = isset($shortOpts[$i + 2]) && $shortOpts[$i + 2] === ':' ? 2 : 1; } $specs[$c] = $mode; } for ($i = 0, $n = count($tokens); $i < $n; $i++) { $tok = $tokens[$i]; if ($tok === '--') { break; // end of options } if (str_starts_with($tok, '--')) { $name = substr($tok, 2); $val = null; if (str_contains($name, '=')) { [$name, $val] = explode('=', $name, 2); } $mode = $specs[$name] ?? -1; if ($mode === -1) { continue; // unknown option } if ($mode === 1 && $val === null) { $val = $tokens[++$i] ?? false; } elseif ($mode === 0) { $val = false; } elseif ($mode === 2 && $val === null) { $val = false; } // Support repeated options as arrays (matches getopt() behaviour) if (array_key_exists($name, $result)) { $result[$name] = array_merge((array) $result[$name], [$val]); } else { $result[$name] = $val; } } elseif (str_starts_with($tok, '-') && strlen($tok) > 1) { $chars = substr($tok, 1); for ($j = 0, $jn = strlen($chars); $j < $jn; $j++) { $c = $chars[$j]; $mode = $specs[$c] ?? -1; if ($mode === -1) { continue; } $val = false; if ($mode === 1) { $rest = substr($chars, $j + 1); if ($rest !== '') { $val = $rest; $j = $jn; // consumed rest of cluster } else { $val = $tokens[++$i] ?? false; } } $result[$c] = $val; } } } return $result; } /** * Print help message */ protected function printHelp(): void { echo "{$this->description}\n\n"; echo "Usage: {$this->name} [options]\n\n"; echo "Options:\n"; $allOpts = array_merge($this->getCommonOptions(), $this->setupArguments()); foreach ($allOpts as $opt => $desc) { $optName = rtrim($opt, ':'); $hasValue = str_ends_with($opt, ':'); $optDisplay = strlen($optName) === 1 ? "-{$optName}" : "--{$optName}"; if ($hasValue) { $optDisplay .= ' '; } echo sprintf(" %-30s %s\n", $optDisplay, $desc); } echo "\n"; } /** * Setup logging */ protected function setupLogging(): void { if ($this->quiet) { error_reporting(E_ERROR); } elseif ($this->verbose) { error_reporting(E_ALL); } } /** * Setup enterprise features */ protected function setupEnterpriseFeatures(): void { if (isset($this->options['metrics'])) { try { $this->metrics = new MetricsCollector($this->name); $this->log("Metrics collection enabled", 'INFO'); } catch (Exception $e) { $this->log("Metrics collection unavailable: " . $e->getMessage(), 'WARNING'); } } } /** * Execute the CLI application * * @return int Exit code */ public function execute(): int { try { $this->parseArguments(); $this->setupLogging(); $this->setupEnterpriseFeatures(); $this->log("Starting {$this->name} v{$this->version}", 'INFO'); if ($this->dryRun) { $this->log("DRY RUN MODE - No changes will be made", 'INFO'); } $startTime = microtime(true); if ($this->metrics !== null) { $timer = $this->metrics->startTimer('main_execution'); $exitCode = $this->run(); $timer->stop($exitCode === 0); } else { $exitCode = $this->run(); } $duration = microtime(true) - $startTime; $this->log(sprintf("Completed {$this->name} with exit code %d (%.2fs)", $exitCode, $duration), 'INFO'); if ($this->metrics !== null && !$this->quiet) { $this->metrics->printSummary(); } return $exitCode; } catch (Exception $e) { $this->log("Unhandled exception: " . $e->getMessage(), 'ERROR'); if ($this->verbose) { $this->log($e->getTraceAsString(), 'ERROR'); } return 1; } } /** * Get option value * * @param string $name Option name * @param mixed $default Default value if not set * @return mixed Option value */ protected function getOption(string $name, $default = null) { return $this->options[$name] ?? $default; } /** * Check if option is set * * @param string $name Option name * @return bool True if option is set */ protected function hasOption(string $name): bool { return isset($this->options[$name]); } /** * Log a message * * @param string $message Message to log * @param string $level Log level (INFO, WARNING, ERROR) */ protected function log(string $message, string $level = 'INFO'): void { if ($this->quiet && $level !== 'ERROR') { return; } if (!$this->verbose && $level === 'DEBUG') { return; } $timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $formatted = "[{$timestamp}] {$level}: {$message}\n"; if ($level === 'ERROR') { fwrite(STDERR, $formatted); } else { echo $formatted; } } /** * Print result in appropriate format * * @param mixed $result Result to print */ protected function printResult($result): void { if ($this->jsonOutput) { echo json_encode($result, JSON_PRETTY_PRINT) . "\n"; } else { if (is_array($result)) { echo var_export($result, true) . "\n"; } else { echo $result . "\n"; } } } /** * Ask for user confirmation * * @param string $message Confirmation message * @param bool $default Default response * @return bool True if user confirms */ protected function confirm(string $message, bool $default = false): bool { if ($this->dryRun) { $this->log("[DRY RUN] Would ask: {$message}", 'INFO'); return false; } $suffix = $default ? ' [Y/n]' : ' [y/N]'; echo $message . $suffix . ': '; $handle = fopen('php://stdin', 'r'); $response = trim(fgets($handle)); fclose($handle); if (empty($response)) { return $default; } return in_array(strtolower($response), ['y', 'yes'], true); } /** * Print colored output (if terminal supports it) * * @param string $text Text to print * @param string $color Color name (red, green, yellow, blue, cyan, gray, bold, dim) */ protected function printColored(string $text, string $color): void { $colors = [ 'red' => "\033[31m", 'green' => "\033[32m", 'yellow' => "\033[33m", 'blue' => "\033[34m", 'cyan' => "\033[36m", 'gray' => "\033[90m", 'bold' => "\033[1m", 'dim' => "\033[2m", 'reset' => "\033[0m", ]; if (isset($colors[$color]) && $this->isColorEnabled()) { echo $colors[$color] . $text . $colors['reset']; } else { echo $text; } } // ========================================================================= // Console graphics — visual primitives added to CLIApp // ========================================================================= /** * Return whether ANSI colour output is enabled. * * Disabled when --no-color is passed, NO_COLOR env var is set, or * stdout is not an interactive terminal. */ protected function isColorEnabled(): bool { static $cache = null; if ($cache !== null) { return $cache; } if (in_array('--no-color', $_SERVER['argv'] ?? [], true) || getenv('NO_COLOR') !== false) { return $cache = false; } return $cache = stream_isatty(STDOUT); } /** * Return the terminal width (defaults to 80). */ protected function termWidth(): int { $cols = (int) getenv('COLUMNS'); return ($cols > 40) ? $cols : 80; } /** * Wrap text in an ANSI code; returns plain text when colour is off. */ protected function colorize(string $code, string $text): string { if (!$this->isColorEnabled()) { return $text; } return $code . $text . "\033[0m"; } /** * Print a script header banner. * * @param string $name Script name (defaults to $this->name). * @param string $desc One-line description. * @param string $ver Version string (defaults to $this->version). */ protected function printBanner(string $name = '', string $desc = '', string $ver = ''): void { $name = $name ?: $this->name; $ver = $ver ?: $this->version; $w = min($this->termWidth(), 70); $inner = $w - 2; $titlePad = str_pad(" {$name} v{$ver}", $inner); $descPad = ($desc !== '') ? str_pad(" {$desc}", $inner) : null; echo "\n"; echo $this->colorize( "\033[36m", "\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}" ) . "\n"; echo $this->colorize("\033[36m", "\u{2502}") . $this->colorize("\033[1m", $titlePad) . $this->colorize("\033[36m", "\u{2502}") . "\n"; if ($descPad !== null) { echo $this->colorize("\033[36m", "\u{2502}") . $this->colorize("\033[2m", $descPad) . $this->colorize("\033[36m", "\u{2502}") . "\n"; } echo $this->colorize( "\033[36m", "\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}" ) . "\n\n"; } /** * Print a section header rule. * * Output example: ── Section Title ────────────────────────── */ protected function section(string $title): void { $w = $this->termWidth(); $text = " {$title} "; $fill = max(0, $w - strlen($text) - 4); echo "\n"; echo $this->colorize( "\033[36m", str_repeat("\u{2500}", 2) . $text . str_repeat("\u{2500}", $fill) ) . "\n\n"; } /** * Print a plain horizontal divider. */ protected function printDivider(): void { echo $this->colorize("\033[2m", str_repeat("\u{2500}", $this->termWidth())) . "\n"; } /** * Print a single pass/fail status line. * * @param bool $passed Whether the check passed. * @param string $label Check description. * @param string $detail Optional detail in dim text. */ protected function statusLine(bool $passed, string $label, string $detail = ''): void { [$icon, $color] = $passed ? ["\u{2713}", "\033[32m"] : ["\u{2717}", "\033[31m"]; $suffix = ($detail !== '') ? ' ' . $this->colorize("\033[2m", "— {$detail}") : ''; echo ' ' . $this->colorize($color . "\033[1m", $icon) . ' ' . $label . $suffix . "\n"; } /** * Render an in-place progress bar. * * @param int $current Items done. * @param int $total Total items. * @param string $label Optional trailing label. * @param bool $newline Finish bar with newline. */ protected function progress(int $current, int $total, string $label = '', bool $newline = false): void { $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->colorize("\033[32m", str_repeat("\u{2588}", $filled)) . $this->colorize("\033[2m", str_repeat("\u{2591}", $barWidth - $filled)); $line = sprintf( ' [%s] %s %s%s', $bar, $this->colorize("\033[1m", sprintf('%3d%%', $pct)), $this->colorize("\033[2m", "({$current}/{$total})"), ($label !== '') ? " {$label}" : '' ); echo $newline ? "\r{$line}\n" : "\r{$line}"; } /** * Print a bordered summary box. * * @param array $rows Label => value pairs. * @param bool|null $passed Border colour: green/red/cyan. */ protected function printSummaryBox(array $rows, ?bool $passed = null): void { $color = match ($passed) { true => "\033[32m", false => "\033[31m", default => "\033[36m", }; $maxKey = max(array_map('strlen', array_keys($rows))); $inner = $maxKey + 20; echo "\n"; echo $this->colorize($color, "\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}") . "\n"; foreach ($rows as $label => $value) { $valStr = (string) $value; $padding = $inner - strlen($label) - strlen($valStr) - 4; $row = ' ' . $this->colorize("\033[1m", $label) . str_repeat(' ', max(1, $padding)) . $valStr . ' '; echo $this->colorize($color, "\u{2502}") . $row . $this->colorize($color, "\u{2502}") . "\n"; } echo $this->colorize($color, "\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}") . "\n\n"; } public function getVersion(): string { return $this->version; } } /** * CLI for validation operations */ class ValidationCLI extends CLIApp { protected function setupArguments(): array { return [ 'check:' => 'Type of validation (all, paths, markdown, licenses, workflows, security)', 'dir:' => 'Directory to validate (default: current directory)', ]; } protected function run(): int { $check = $this->getOption('check', 'all'); $dir = $this->getOption('dir', '.'); $this->log("Running validation: {$check}", 'INFO'); try { $validator = new UnifiedValidator(); if (in_array($check, ['all', 'paths'], true)) { $validator->addPlugin(new PathValidatorPlugin()); } if (in_array($check, ['all', 'markdown'], true)) { $validator->addPlugin(new MarkdownValidatorPlugin()); } $context = [ 'paths' => [$dir], 'scan_dir' => $dir, ]; $results = $validator->validateAll($context); if (!$this->jsonOutput) { $validator->printSummary(); } else { $resultData = array_map(function ($r) { return [ 'plugin' => $r->pluginName, 'passed' => $r->passed, 'message' => $r->message, 'details' => $r->details, ]; }, $results); $this->printResult($resultData); } return $validator->allPassed() ? 0 : 1; } catch (Exception $e) { $this->log("Validation error: " . $e->getMessage(), 'ERROR'); return 1; } } } // ============================================================================= // CliFramework — current base class for all MokoStandards CLI scripts // ============================================================================= /** * Base class for MokoStandards 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}"; // ░ // ------------------------------------------------------------------------- // 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; // ========================================================================= // 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(); echo $this->c(self::C_BOLD . self::C_CYAN, $this->scriptName); if ($this->description !== '') { echo ' — ' . $this->description; } echo "\n"; echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n"; echo $this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n"; echo $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})") : ''; printf( " %s%-22s%s%s%s\n", self::C_CYAN, $name, self::C_RESET, $def['desc'], $hint ); } echo "\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; echo "\n"; echo $this->c( self::C_CYAN, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR ) . "\n"; echo $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) { echo $this->c(self::C_CYAN, self::BOX_V) . $this->c(self::C_DIM, $descLine) . $this->c(self::C_CYAN, self::BOX_V) . "\n"; } echo $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); echo $this->c( self::C_YELLOW . self::C_BOLD, self::BOX_TL . str_repeat(self::BOX_H, $w - 2) . self::BOX_TR ) . "\n"; echo $this->c( self::C_YELLOW . self::C_BOLD, self::BOX_V . $row . self::BOX_V ) . "\n"; echo $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); echo "\n"; echo $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(); echo $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"; if ($level === 'ERROR') { fwrite(STDERR, $line); } else { echo $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}") : ''; echo ' ' . $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) { echo "\r{$line}\n"; $this->progressActive = false; } else { echo "\r{$line}"; $this->progressActive = true; } } /** Clear the active progress bar line. */ protected function clearProgress(): void { if ($this->progressActive) { echo "\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; echo "\n"; echo $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 . ' '; echo $this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n"; } echo $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); echo "\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)); } }