* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Lib * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/CliBase.php * BRIEF: Standalone base CLI class for scripts that do not use CliFramework */ declare(strict_types=1); /** * Base CLI Application Class * * Provides common functionality for command-line scripts that do not * require the full CliFramework enterprise stack. */ abstract class CliBase { protected array $args = []; protected array $options = []; protected bool $verbose = false; protected bool $dryRun = false; protected string $scriptName; /** * @param array $argv Command-line argument vector. */ public function __construct(array $argv) { $this->scriptName = basename($argv[0] ?? 'script'); $this->parseArguments(array_slice($argv, 1)); $this->verbose = $this->hasOption('verbose') || $this->hasOption('v'); $this->dryRun = $this->hasOption('dry-run'); } /** * Parse command-line arguments into options and positional args. * * @param array $args Argument list (argv without argv[0]). */ private function parseArguments(array $args): void { foreach ($args as $arg) { if (str_starts_with($arg, '--')) { $parts = explode('=', substr($arg, 2), 2); $this->options[$parts[0]] = $parts[1] ?? true; } elseif (str_starts_with($arg, '-')) { $this->options[substr($arg, 1)] = true; } else { $this->args[] = $arg; } } } /** * Get positional argument by index. * * @param int $index Zero-based position. * @param mixed $default Fallback when argument is absent. * @return mixed */ protected function getArg(int $index, mixed $default = null): mixed { return $this->args[$index] ?? $default; } /** * Get option value. * * @param string $name Option name (without leading dashes). * @param mixed $default Fallback when option is absent. * @return mixed */ protected function getOption(string $name, mixed $default = null): mixed { return $this->options[$name] ?? $default; } /** * Check if option exists. * * @param string $name Option name (without leading dashes). */ protected function hasOption(string $name): bool { return isset($this->options[$name]); } // ------------------------------------------------------------------------- // Console graphics constants // ------------------------------------------------------------------------- private const ICONS = [ 'SUCCESS' => "\u{2713}", // ✓ 'ERROR' => "\u{2717}", // ✗ 'WARNING' => "\u{26A0}", // ⚠ 'INFO' => "\u{2192}", // → ]; private const ANSI = [ 'ERROR' => "\033[31m", 'SUCCESS' => "\033[32m", 'WARNING' => "\033[33m", 'INFO' => "\033[36m", 'BOLD' => "\033[1m", 'DIM' => "\033[2m", 'GRAY' => "\033[90m", 'CYAN' => "\033[36m", 'MAGENTA' => "\033[35m", 'RESET' => "\033[0m", ]; /** Cached terminal-colour detection result. */ private ?bool $cliColorEnabled = null; // ------------------------------------------------------------------------- // Colour helper // ------------------------------------------------------------------------- /** * Return whether ANSI colour output should be used. */ protected function isColorEnabled(): bool { if ($this->cliColorEnabled !== null) { return $this->cliColorEnabled; } if (isset($this->options['no-color']) || getenv('NO_COLOR') !== false) { return $this->cliColorEnabled = false; } return $this->cliColorEnabled = stream_isatty(STDOUT); } /** * Wrap text in an ANSI colour; returns plain text when colour is off. */ protected function colorize(string $code, string $text): string { if (!$this->isColorEnabled()) { return $text; } return $code . $text . self::ANSI['RESET']; } /** * Return the terminal width (defaults to 80). */ protected function termWidth(): int { $cols = (int) getenv('COLUMNS'); return ($cols > 40) ? $cols : 80; } // ------------------------------------------------------------------------- // Logging // ------------------------------------------------------------------------- /** * Print a levelled message to stdout. * * @param string $message Text to display. * @param string $level One of INFO, SUCCESS, WARNING, ERROR. */ protected function log(string $message, string $level = 'INFO'): void { $level = strtoupper($level); $color = self::ANSI[$level] ?? ''; $icon = self::ICONS[$level] ?? self::ICONS['INFO']; $reset = self::ANSI['RESET']; if ($this->isColorEnabled()) { $badge = "{$color}" . self::ANSI['BOLD'] . "[{$level}]{$reset}"; $icon = "{$color}{$icon}{$reset}"; } else { $badge = "[{$level}]"; } $line = "{$icon} {$badge} {$message}\n"; if ($level === 'ERROR') { fwrite(STDERR, $line); } else { echo $line; } } /** * Print verbose message (only when --verbose or -v is set). * * @param string $message Text to display. */ protected function verbose(string $message): void { if ($this->verbose) { $this->log($message, 'INFO'); } } /** * Print error message and exit. * * @param string $message Error text. * @param int $exitCode Process exit code. * @return never */ protected function error(string $message, int $exitCode = 1): never { $this->log($message, 'ERROR'); exit($exitCode); } /** * Print success message. * * @param string $message Text to display. */ protected function success(string $message): void { $this->log($message, 'SUCCESS'); } /** * Print warning message. * * @param string $message Text to display. */ protected function warning(string $message): void { $this->log($message, 'WARNING'); } /** * Ask user for confirmation (reads from stdin). * * @param string $question Prompt text. * @return bool True when user enters 'y'. */ protected function confirm(string $question): bool { echo "{$question} [y/N]: "; $handle = fopen('php://stdin', 'r'); $line = fgets($handle); fclose($handle); return strtolower(trim((string) $line)) === 'y'; } /** * Print usage/help information. */ abstract protected function showHelp(): void; /** * Main execution method. * * @return int Exit code (0 = success). */ abstract protected function execute(): int; /** * Run the application, dispatching --help and catching exceptions. * * @return int Exit code. */ public function run(): int { if ($this->hasOption('help') || $this->hasOption('h')) { $this->showHelp(); return 0; } if ($this->dryRun) { $this->warning('Dry-run mode enabled - no changes will be made'); } try { return $this->execute(); } catch (\Exception $e) { $this->log('Error: ' . $e->getMessage(), 'ERROR'); return 1; } } /** * Execute a shell command and return its output. * * In dry-run mode the command is logged but not executed. * * @param string $command Shell command string. * @param array|null &$output Lines of output (populated by reference). * @param int|null &$exitCode Process exit code (populated by reference). * @return string Last line of output. */ protected function exec(string $command, ?array &$output = null, ?int &$exitCode = null): string { $this->verbose("Executing: {$command}"); if ($this->dryRun) { $this->log("[DRY-RUN] Would execute: {$command}"); return ''; } $result = exec($command, $output, $exitCode); if ($exitCode !== 0) { $this->warning("Command failed with exit code {$exitCode}"); } return (string) $result; } /** * Run command and return success status. * * @param string $command Shell command string. * @return bool True when exit code is 0. */ protected function runCommand(string $command): bool { $exitCode = 0; $this->exec($command, $output, $exitCode); return $exitCode === 0; } /** * Read file contents. * * @param string $path File path to read. * @return string File contents. * @throws \RuntimeException When file does not exist. */ protected function readFile(string $path): string { if (!file_exists($path)) { throw new \RuntimeException("File not found: {$path}"); } return (string) file_get_contents($path); } /** * Write file contents, creating parent directories as needed. * * In dry-run mode the write is logged but not performed. * * @param string $path Destination file path. * @param string $content Content to write. */ protected function writeFile(string $path, string $content): void { if ($this->dryRun) { $this->log("[DRY-RUN] Would write to: {$path}"); return; } $dir = dirname($path); if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($path, $content); $this->verbose("Written: {$path}"); } /** * Copy a file, creating the destination directory if needed. * * In dry-run mode the copy is logged but not performed. * * @param string $source Source file path. * @param string $dest Destination file path. */ protected function copyFile(string $source, string $dest): void { if ($this->dryRun) { $this->log("[DRY-RUN] Would copy: {$source} -> {$dest}"); return; } $dir = dirname($dest); if (!is_dir($dir)) { mkdir($dir, 0755, true); } copy($source, $dest); $this->verbose("Copied: {$source} -> {$dest}"); } /** * Delete a file or directory. * * In dry-run mode the deletion is logged but not performed. * * @param string $path Path to delete. */ protected function delete(string $path): void { if ($this->dryRun) { $this->log("[DRY-RUN] Would delete: {$path}"); return; } if (is_dir($path)) { $this->deleteDirectory($path); } elseif (file_exists($path)) { unlink($path); } $this->verbose("Deleted: {$path}"); } // ------------------------------------------------------------------------- // Console graphics — visual primitives // ------------------------------------------------------------------------- /** * Print a script header banner with name and description. * * @param string $name Script name. * @param string $desc One-line description. * @param string $ver Version string. */ protected function printBanner(string $name, string $desc = '', string $ver = '04.00.15'): void { $w = min($this->termWidth(), 70); $inner = $w - 2; $h = "\u{2500}"; $v = "\u{2502}"; $tl = "\u{250C}"; $tr = "\u{2510}"; $bl = "\u{2514}"; $br = "\u{2518}"; $title = " {$name} v{$ver}"; $titlePad = str_pad($title, $inner); $descPad = ($desc !== '') ? str_pad(" {$desc}", $inner) : null; echo "\n"; echo $this->colorize(self::ANSI['CYAN'], $tl . str_repeat($h, $inner) . $tr) . "\n"; echo $this->colorize(self::ANSI['CYAN'], $v) . $this->colorize(self::ANSI['BOLD'], $titlePad) . $this->colorize(self::ANSI['CYAN'], $v) . "\n"; if ($descPad !== null) { echo $this->colorize(self::ANSI['CYAN'], $v) . $this->colorize(self::ANSI['DIM'], $descPad) . $this->colorize(self::ANSI['CYAN'], $v) . "\n"; } echo $this->colorize(self::ANSI['CYAN'], $bl . str_repeat($h, $inner) . $br) . "\n\n"; } /** * Print a section header rule. * * Output example: ── Section Title ────────────────────────── */ protected function section(string $title): void { $h = "\u{2500}"; $w = $this->termWidth(); $text = " {$title} "; $fill = max(0, $w - strlen($text) - 4); echo "\n"; echo $this->colorize(self::ANSI['CYAN'], str_repeat($h, 2) . $text . str_repeat($h, $fill)) . "\n\n"; } /** * Print a plain horizontal divider. */ protected function printDivider(): void { echo $this->colorize(self::ANSI['DIM'], 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}", self::ANSI['SUCCESS']] : ["\u{2717}", self::ANSI['ERROR']]; $suffix = ($detail !== '') ? ' ' . $this->colorize(self::ANSI['DIM'], "— {$detail}") : ''; echo ' ' . $this->colorize($color . self::ANSI['BOLD'], $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(self::ANSI['SUCCESS'], str_repeat("\u{2588}", $filled)) . $this->colorize(self::ANSI['DIM'], str_repeat("\u{2591}", $barWidth - $filled)); $line = sprintf( ' [%s] %s %s%s', $bar, $this->colorize(self::ANSI['BOLD'], sprintf('%3d%%', $pct)), $this->colorize(self::ANSI['DIM'], "({$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 => self::ANSI['SUCCESS'], false => self::ANSI['ERROR'], default => self::ANSI['CYAN'], }; $h = "\u{2500}"; $v = "\u{2502}"; $tl = "\u{250C}"; $tr = "\u{2510}"; $bl = "\u{2514}"; $br = "\u{2518}"; $maxKey = max(array_map('strlen', array_keys($rows))); $inner = $maxKey + 20; echo "\n"; echo $this->colorize($color, $tl . str_repeat($h, $inner) . $tr) . "\n"; foreach ($rows as $label => $value) { $valStr = (string) $value; $padding = $inner - strlen($label) - strlen($valStr) - 4; $row = ' ' . $this->colorize(self::ANSI['BOLD'], $label) . str_repeat(' ', max(1, $padding)) . $valStr . ' '; echo $this->colorize($color, $v) . $row . $this->colorize($color, $v) . "\n"; } echo $this->colorize($color, $bl . str_repeat($h, $inner) . $br) . "\n\n"; } // ------------------------------------------------------------------------- // Recursively delete a directory (private — used by delete()) // ------------------------------------------------------------------------- /** * Recursively delete a directory and all its contents. * * @param string $dir Directory path. */ private function deleteDirectory(string $dir): void { if (!is_dir($dir)) { return; } $files = array_diff((array) scandir($dir), ['.', '..']); foreach ($files as $file) { $path = "{$dir}/{$file}"; is_dir($path) ? $this->deleteDirectory($path) : unlink($path); } rmdir($dir); } }