3f3b1f79a0
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 45s
Added @since, @param, @see tags to: - CliFramework: class-level @since, 2 undocumented methods - GitHubAdapter: class @since/@see, constructor @param, property docs - MokoGiteaAdapter: class @since/@see, constructor @param, property docs - ApiClient: class @since Wiki: created Coding-Standards page with full PHPDoc standard, PHPCS exclusion rationale, and file structure patterns. Partial progress on #137 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1462 lines
48 KiB
PHP
1462 lines
48 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: 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<string, string> 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<string, string> 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<string> $argv Full argv including $argv[0] (script path)
|
|
* @param list<string> $longOpts Long option specs, e.g. ['verbose', 'repos:', 'path::']
|
|
* @param string $shortOpts Short option chars, e.g. 'vqh'
|
|
* @return array<string, string|false|list<string>> 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 .= ' <value>';
|
|
}
|
|
|
|
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<string, string|int|float> $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<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;
|
|
|
|
// =========================================================================
|
|
// 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<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;
|
|
|
|
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));
|
|
}
|
|
}
|