Files
Jonathan Miller 76bc91a383
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
fix: route all decorative CLI output to stderr
All display methods (banner, progress, section, status, log, table,
summary box, divider, step) now write to stderr via a new display()
helper. This ensures stdout is reserved for machine-readable data,
fixing CI pipelines that capture CLI output with $() or redirect to
$GITHUB_OUTPUT.

Root cause: version_read.php banner was written to stdout, so
  VERSION=$(php version_read.php --path .)
captured the box-drawing characters along with the version string,
corrupting $GITHUB_OUTPUT and breaking downstream release steps.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:01:16 -05:00

1048 lines
35 KiB
PHP

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