Add Prerequisite class and cli script to confirm prereqs. (#30)

Add a Prerequisite class to manage all initilaization prerequisites. Invoke before each request. Provide a CLI script to allow checking Prereqs from command line. Add CI  workflow to confirm prereq validation behavior.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/30
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
Greg Sarjeant 2025-07-27 16:43:09 +00:00 committed by greg
parent 4255f46fc7
commit 6337fa2dfb
10 changed files with 742 additions and 71 deletions

View File

@ -0,0 +1,163 @@
name: Prerequisites Testing
on: [pull_request]
jobs:
test-php-version-requirements:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.4', '8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo,pdo_sqlite
- name: Test PHP version requirement
run: |
if [[ "${{ matrix.php }}" < "8.2" ]]; then
echo "Testing PHP ${{ matrix.php }} - should fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed with PHP ${{ matrix.php }}"
exit 1
fi
echo "✓ Correctly failed with old PHP version"
else
echo "Testing PHP ${{ matrix.php }} - should pass"
php check-prerequisites.php
echo "✓ Correctly passed with supported PHP version"
fi
test-extension-progression:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3']
container: ['fedora:39', 'debian:bookworm', 'alpine:latest']
container: ${{ matrix.container }}
steps:
- name: Install Node.js fnd git or actions
run: |
if [ -f /etc/fedora-release ]; then
dnf install -y nodejs npm git
elif [ -f /etc/alpine-release ]; then
apk add --no-cache nodejs npm git
else
apt-get update && apt-get install -y nodejs npm git
fi
- uses: actions/checkout@v3
- name: Install base PHP only
run: |
if [ -f /etc/fedora-release ]; then
dnf --setopt=install_weak_deps=False install -y php php-cli
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82
ln -s /usr/bin/php82 /usr/bin/php
else
apt-get update && apt-get install -y php
fi
- name: Test failure with missing extensions
run: |
echo "Testing with base PHP - should fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed with missing extensions"
exit 1
fi
echo "✓ Correctly failed with missing extensions"
- name: Install PDO extension
run: |
if [ -f /etc/fedora-release ]; then
echo "Not installing PDO on fedora because it includes SQLite support."
echo "Will install in subsequent test so this step fails as expected."
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-pdo
else
apt-get install -y php-pdo
fi
- name: Test still fails without SQLite
run: |
echo "Testing with PDO but no SQLite - should still fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed without SQLite"
exit 1
fi
echo "✓ Correctly failed without SQLite extension"
- name: Install SQLite extension
run: |
if [ -f /etc/fedora-release ]; then
dnf --setopt=install_weak_deps=False install -y php-pdo
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-pdo_sqlite
else
apt-get install -y php-sqlite3
fi
- name: Test now passes with required extensions
run: |
echo "Testing with all required extensions - should pass"
php check-prerequisites.php
echo "✓ All required extensions detected correctly"
- name: Install recommended extensions and retest
run: |
if [ -f /etc/fedora-release ]; then
dnf install -y php-mbstring php-curl
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-mbstring php82-curl
else
apt-get install -y php-mbstring php-curl
fi
php check-prerequisites.php
echo "✓ Recommended extensions also detected"
test-permission-scenarios:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo,pdo_sqlite
- name: Test with unwritable storage directory
run: |
# Create a non-root user for testing
useradd -m -s /bin/bash testuser
# Make storage unwritable by non-root
mkdir -p storage
chmod 444 storage
chown root:root storage
# Run as the non-root user - should fail
if su testuser -c "php check-prerequisites.php"; then
echo "ERROR: Should have failed with unwritable storage"
exit 1
fi
echo "✓ Correctly failed with unwritable storage"
# Restore permissions for cleanup
chmod 755 storage
- name: Test with missing directories
run: |
# Remove required directories
rm -rf src templates config
# Should fail
if php check-prerequisites.php; then
echo "ERROR: Should have failed with missing directories"
exit 1
fi

5
.gitignore vendored
View File

@ -15,4 +15,7 @@ storage/upload/css
scratch
# Build artifacts
tkr.tgz
tkr.tgz
# Test logs
storage/prerequisite-check.log

View File

@ -1,4 +1,5 @@
# tkr
![Prerequisite tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/prerequisites.yaml/badge.svg)
![Unit tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/unit_tests.yaml/badge.svg)
A lightweight, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe).

21
check-prerequisites.php Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
/**
* tkr Prerequisites Checker - CLI Diagnostic Tool
*
* This script provides comprehensive diagnostic information for tkr.
* It can be run from the command line or uploaded separately for troubleshooting.
*
* Usage: php check-prerequisites.php
*/
// Minimal bootstrap just for prerequisites
include_once __DIR__ . '/config/bootstrap.php';
$prerequisites = new Prerequisites();
$results = $prerequisites->validate();
// Exit with appropriate code for shell scripts
if (php_sapi_name() === 'cli') {
exit(count($prerequisites->getErrors()) > 0 ? 1 : 0);
}

View File

@ -9,10 +9,9 @@ define('CONFIG_DIR', APP_ROOT . '/config');
define('SRC_DIR', APP_ROOT . '/src');
define('STORAGE_DIR', APP_ROOT . '/storage');
define('TEMPLATES_DIR', APP_ROOT . '/templates');
define('TICKS_DIR', STORAGE_DIR . '/ticks');
define('DATA_DIR', STORAGE_DIR . '/db');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
// Janky autoloader function
// This is a bit more consistent with current frameworks

View File

@ -14,20 +14,27 @@ if (preg_match('/\.php$/', $path)) {
// Define base paths and load classes
include_once(dirname(dirname(__FILE__)) . "/config/bootstrap.php");
// validate that necessary directories exist and are writable
$fsMgr = new Filesystem();
$fsMgr->validate();
// Check prerequisites.
$prerequisites = new Prerequisites();
$results = $prerequisites->validate();
if (count($prerequisites->getErrors()) > 0) {
$prerequisites->generateWebSummary($results);
exit;
}
// do any necessary database migrations
// Do any necessary database migrations
$dbMgr = new Database();
$dbMgr->migrate();
// Make sure the initial setup is complete
// unless we're already heading to setup
//
// TODO: Consider simplifying this.
// Might not need the custom exception now that the prereq checker is more robust.
if (!(preg_match('/setup$/', $path))) {
try {
// database validation
$dbMgr->validate();
// Make sure setup has been completed
$dbMgr->confirmSetup();
} catch (SetupException $e) {
$e->handle();
exit;

View File

@ -119,7 +119,7 @@ class Database{
}
// make sure tables that need to be seeded have been
private function validateTableContents(): void {
public function confirmSetup(): void {
$db = self::get();
// make sure required tables (user, settings) are populated

View File

@ -13,13 +13,8 @@ class SetupException extends Exception {
// but this is a very specific case.
public function handle(){
switch ($this->setupIssue){
case 'storage_missing':
case 'storage_permissions':
case 'directory_creation':
case 'directory_permissions':
case 'database_connection':
case 'load_classes':
case 'table_creation':
case 'db_migration':
// Unrecoverable errors.
// Show error message and exit
http_response_code(500);
@ -31,7 +26,7 @@ class SetupException extends Exception {
// Redirect to setup if we aren't already headed there.
// NOTE: Just read directly from init.php instead of
// trying to use the config object. This is the initial
// setup. It shouldn't assume anything can be loaded.
// setup. It shouldn't assume any data can be loaded.
$init = require APP_ROOT . '/config/init.php';
$currentPath = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');

View File

@ -1,54 +0,0 @@
<?php
// Validates that required directories exists
// and files have correct formats
class Filesystem {
public function validate(): void{
$this->validateStorageDir();
$this->validateStorageSubdirs();
}
// Make sure the storage/ directory exists and is writable
private function validateStorageDir(): void{
if (!is_dir(STORAGE_DIR)) {
throw new SetupException(
STORAGE_DIR . "does not exist. Please check your installation.",
'storage_missing'
);
}
if (!is_writable(STORAGE_DIR)) {
throw new SetupException(
STORAGE_DIR . "is not writable. Exiting.",
'storage_permissions'
);
}
}
// validate that the required storage subdirectories exist
// attempt to create them if they don't
private function validateStorageSubdirs(): void {
$storageSubdirs = array();
$storageSubdirs[] = CSS_UPLOAD_DIR;
$storageSubdirs[] = DATA_DIR;
foreach($storageSubdirs as $storageSubdir){
if (!is_dir($storageSubdir)) {
if (!mkdir($storageSubdir, 0770, true)) {
throw new SetupException(
"Failed to create required directory: $dir",
'directory_creation'
);
}
}
if (!is_writable($storageSubdir)) {
if (!chmod($storageSubdir, 0770)) {
throw new SetupException(
"Required directory is not writable: $dir",
'directory_permissions'
);
}
}
}
}
}

View File

@ -0,0 +1,536 @@
<?php
/**
* tkr Prerequisites Checker
*
* This class checks all system requirements for tkr and provides
* detailed logging of any missing components or configuration issues.
*
* ZERO DEPENDENCIES - Uses only core PHP functions available since PHP 5.3
*/
class Prerequisites {
private $checks = array();
private $warnings = array();
private $errors = array();
private $baseDir;
private $logFile;
private $isCli;
private $isWeb;
public function __construct() {
$this->isCli = php_sapi_name() === 'cli';
$this->isWeb = !$this->isCli && isset($_SERVER['HTTP_HOST']);
$this->baseDir = APP_ROOT;
$this->logFile = $this->baseDir . '/storage/prerequisite-check.log';
if ($this->isWeb) {
header('Content-Type: text/html; charset=utf-8');
}
}
/** Log validation output
*
* This introduces a chicken-and-egg problem, because
* if the storage directory isn't writable, this will fail.
* In that case, I'll just write to stdout.
*
*/
private function log($message, $overwrite=false) {
$logDir = dirname($this->logFile);
//print("Log dir: {$logDir}");
if (!is_dir($logDir)) {
if (!@mkdir($logDir, 0770, true)) {
// Can't create storage dir - just output, don't log to file
if ($this->isCli) {
echo $message . "\n";
}
return;
}
}
$flags = LOCK_EX;
if (!$overwrite) {
$flags |= FILE_APPEND;
}
// Try to write to log file
if (@file_put_contents($this->logFile, $message . "\n", $flags) === false) {
// Logging failed, but continue - just output to CLI if possible
if ($this->isCli) {
echo "Warning: Could not write to log file\n";
}
}
if ($this->isCli) {
echo $message . "\n";
}
}
private function addCheck($name, $status, $message, $severity = 'info') {
$this->checks[] = array(
'name' => $name,
'status' => $status,
'message' => $message,
'severity' => $severity
);
if ($severity === 'error') {
$this->errors[] = $message;
} elseif ($severity === 'warning') {
$this->warnings[] = $message;
}
$statusIcon = $status ? '✓' : '✗';
$this->log("[{$statusIcon}] {$name}: {$message}");
}
private function checkPhpVersion() {
$minVersion = '8.2.0';
$currentVersion = PHP_VERSION;
$versionOk = version_compare($currentVersion, $minVersion, '>=');
if ($versionOk) {
$this->addCheck(
'PHP Version',
true,
"PHP {$currentVersion} (meets minimum requirement of {$minVersion})"
);
} else {
$this->addCheck(
'PHP Version',
false,
"PHP {$currentVersion} is below minimum requirement of {$minVersion}",
'error'
);
}
return $versionOk;
}
private function checkRequiredExtensions() {
$requiredExtensions = array('PDO', 'pdo_sqlite');
$allRequired = true;
foreach ($requiredExtensions as $ext) {
$loaded = extension_loaded($ext);
$this->addCheck(
"PHP Extension: {$ext}",
$loaded,
$loaded ? 'Available' : "Missing (REQUIRED) - {$ext}",
$loaded ? 'info' : 'error'
);
if (!$loaded) {
$allRequired = false;
}
}
return $allRequired;
}
private function checkRecommendedExtensions() {
$recommendedExtensions = array('mbstring', 'curl', 'fileinfo', 'session');
foreach ($recommendedExtensions as $ext) {
$loaded = extension_loaded($ext);
$this->addCheck(
"PHP Extension: {$ext}",
$loaded,
$loaded ? 'Available' : "Missing (recommended) - {$ext}",
$loaded ? 'info' : 'warning'
);
}
}
private function checkDirectoryStructure() {
$baseDir = $this->baseDir;
$requiredDirs = array(
'config' => 'Configuration files',
'public' => 'Web server document root',
'src' => 'Application source code',
'storage' => 'Data storage (must be writable)',
'templates' => 'Template files'
);
$allPresent = true;
foreach ($requiredDirs as $dir => $description) {
$path = $baseDir . '/' . $dir;
$exists = is_dir($path);
$this->addCheck(
"Directory: {$dir}",
$exists,
$exists ? "Present - {$description}" : "Missing - {$description} at {$path}",
$exists ? 'info' : 'error'
);
if (!$exists) {
$allPresent = false;
}
}
return $allPresent;
}
private function checkStoragePermissions() {
$storageDirs = array(
'storage',
'storage/db',
'storage/upload',
'storage/upload/css'
);
$allWritable = true;
foreach ($storageDirs as $dir) {
$path = $this->baseDir . '/' . $dir;
if (!is_dir($path)) {
// Try to create the directory
$created = @mkdir($path, 0770, true);
if ($created) {
$this->addCheck(
"Storage Directory: {$dir}",
true,
"Created with correct permissions (0770)"
);
} else {
$this->addCheck(
"Storage Directory: {$dir}",
false,
"Could not create directory: {$dir}",
'error'
);
$allWritable = false;
continue;
}
}
$writable = is_writable($path);
$permissions = substr(sprintf('%o', fileperms($path)), -4);
$this->addCheck(
"Storage Permissions: {$dir}",
$writable,
$writable ? "Writable (permissions: {$permissions})" : "Not writable (permissions: {$permissions})",
$writable ? 'info' : 'error'
);
if (!$writable) {
$allWritable = false;
}
}
return $allWritable;
}
private function checkWebServerConfig() {
if ($this->isCli) {
$this->addCheck(
'Web Server Test',
false,
'Cannot test web server configuration from CLI - run via web browser',
'warning'
);
return false;
}
// Check if we're being served from the correct document root
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : '';
$expectedPath = realpath($this->baseDir . '/public');
$correctRoot = ($documentRoot === $expectedPath);
$this->addCheck(
'Document Root',
$correctRoot,
$correctRoot ?
"Correctly set to {$expectedPath}" :
"Should be {$expectedPath}, currently {$documentRoot}",
$correctRoot ? 'info' : 'warning'
);
// Check for URL rewriting
$rewriteWorking = isset($_SERVER['REQUEST_URI']);
$this->addCheck(
'URL Rewriting',
$rewriteWorking,
$rewriteWorking ? 'Available' : 'May not be properly configured',
$rewriteWorking ? 'info' : 'warning'
);
return true;
}
private function checkConfiguration() {
$configFile = $this->baseDir . '/config/init.php';
$configExists = file_exists($configFile);
if (!$configExists) {
$this->addCheck(
'Configuration File',
false,
'config/init.php not found',
'error'
);
return false;
}
try {
$config = include $configFile;
$hasBaseUrl = isset($config['base_url']) && !empty($config['base_url']);
$hasBasePath = isset($config['base_path']) && !empty($config['base_path']);
$this->addCheck(
'Configuration File',
true,
'config/init.php exists and is readable'
);
$this->addCheck(
'Base URL Configuration',
$hasBaseUrl,
$hasBaseUrl ? "Set to: {$config['base_url']}" : 'Not configured',
$hasBaseUrl ? 'info' : 'warning'
);
$this->addCheck(
'Base Path Configuration',
$hasBasePath,
$hasBasePath ? "Set to: {$config['base_path']}" : 'Not configured',
$hasBasePath ? 'info' : 'warning'
);
return $hasBaseUrl && $hasBasePath;
} catch (Exception $e) {
$this->addCheck(
'Configuration File',
false,
'Error reading config/init.php: ' . $e->getMessage(),
'error'
);
return false;
}
}
private function checkDatabase() {
$dbFile = $this->baseDir . '/storage/db/tkr.sqlite';
$dbDir = dirname($dbFile);
if (!is_dir($dbDir)) {
$created = @mkdir($dbDir, 0770, true);
if (!$created) {
$this->addCheck(
'Database Directory',
false,
'Could not create storage/db directory',
'error'
);
return false;
}
}
$canCreateDb = is_writable($dbDir);
$this->addCheck(
'Database Directory',
$canCreateDb,
$canCreateDb ? 'Writable - can create database' : 'Not writable - cannot create database',
$canCreateDb ? 'info' : 'error'
);
if (file_exists($dbFile)) {
$dbReadable = is_readable($dbFile);
$dbWritable = is_writable($dbFile);
$this->addCheck(
'Database File',
$dbReadable && $dbWritable,
$dbReadable && $dbWritable ? 'Exists and is accessible' : 'Exists but has permission issues',
$dbReadable && $dbWritable ? 'info' : 'error'
);
} else {
$this->addCheck(
'Database File',
true,
'Will be created on first run'
);
}
return $canCreateDb;
}
// validate prereqs
// runs on each request and can be run from CLI
public function validate() {
$this->log("=== tkr prerequisites check started at " . date('Y-m-d H:i:s') . " ===", true);
if ($this->isCli) {
$this->log("\n🔍 Validating prerequisites...\n");
}
$results = array(
'php_version' => $this->checkPhpVersion(),
'critical_extensions' => $this->checkRequiredExtensions(),
'directory_structure' => $this->checkDirectoryStructure(),
'storage_permissions' => $this->checkStoragePermissions(),
'web_server' => $this->checkWebServerConfig(),
'configuration' => $this->checkConfiguration(),
'database' => $this->checkDatabase()
);
// Check recommended extensions too
$this->checkRecommendedExtensions();
if ($this->isCli) {
$this->generateCliSummary($results);
}
return $results;
}
/**
* Display web-friendly error page when minimum requirements aren't met
*/
public function generateWebSummary() {
echo '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tkr - Setup Required</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 2rem; line-height: 1.6; background: #f8f9fa; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { text-align: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #dee2e6; }
.header h1 { color: #dc3545; margin: 0; }
.header p { color: #6c757d; margin: 0.5rem 0 0 0; }
.error-item { margin: 1rem 0; padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; border-left: 4px solid #dc3545; }
.warning-item { margin: 1rem 0; padding: 1rem; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; border-left: 4px solid #ffc107; }
.error-title { font-weight: 600; color: #721c24; margin-bottom: 0.5rem; }
.warning-title { font-weight: 600; color: #856404; margin-bottom: 0.5rem; }
.resolution { margin-top: 2rem; padding: 1rem; background: #e9ecef; border-radius: 4px; }
.resolution h3 { margin-top: 0; color: #495057; }
.resolution ul { margin: 0; }
.resolution li { margin: 0.5rem 0; }
.log-info { margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 4px; font-size: 0.9em; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ Setup Required</h1>
<p>tkr cannot start due to system configuration issues</p>
</div>';
$hasErrors = false;
$hasWarnings = false;
// Display errors
foreach ($this->checks as $check) {
if (!$check['status'] && $check['severity'] === 'error') {
if (!$hasErrors) {
echo '<h2>Critical Issues</h2>';
$hasErrors = true;
}
echo '<div class="error-item">
<div class="error-title"> ' . htmlspecialchars($check['name']) . '</div>
' . htmlspecialchars($check['message']) . '
</div>';
}
}
// Display warnings
foreach ($this->checks as $check) {
if (!$check['status'] && $check['severity'] === 'warning') {
if (!$hasWarnings) {
echo '<h2>Warnings</h2>';
$hasWarnings = true;
}
echo '<div class="warning-item">
<div class="warning-title"> ' . htmlspecialchars($check['name']) . '</div>
' . htmlspecialchars($check['message']) . '
</div>';
}
}
// Resolution steps
echo '<div class="resolution">
<h3>How to Fix These Issues</h3>
<ul>';
if (!version_compare(PHP_VERSION, '8.2.0', '>=')) {
echo '<li><strong>PHP Version:</strong> Contact your hosting provider to upgrade PHP to version 8.2 or higher</li>';
}
if (!extension_loaded('PDO') || !extension_loaded('pdo_sqlite')) {
echo '<li><strong>SQLite Support:</strong> Contact your hosting provider to enable PDO and PDO_SQLITE extensions</li>';
}
if (count($this->errors) > 0) {
echo '<li><strong>File Permissions:</strong> Ensure the storage directory and subdirectories are writable by the web server</li>';
echo '<li><strong>Missing Directories:</strong> Upload the complete tkr application with all required directories</li>';
}
echo ' </ul>
<p><strong>Need Help?</strong> Check the tkr documentation or contact your hosting provider with the error details above.</p>
</div>
<div class="log-info">
<p><strong>Technical Details:</strong> Full diagnostic information has been logged to ' . htmlspecialchars($this->logFile) . '</p>
<p><strong>Check Time:</strong> ' . date('Y-m-d H:i:s') . '</p>
</div>
</div>
</body>
</html>';
}
private function generateCliSummary($results) {
$this->log("\n" . str_repeat("=", 60));
$this->log("PREREQUISITE CHECK SUMMARY");
$this->log(str_repeat("=", 60));
$totalChecks = count($this->checks);
$passedChecks = 0;
foreach ($this->checks as $check) {
if ($check['status']) {
$passedChecks++;
}
}
$this->log("Total checks: {$totalChecks}");
$this->log("Passed: {$passedChecks}");
$this->log("Errors: " . count($this->errors));
$this->log("Warnings: " . count($this->warnings));
if (count($this->errors) === 0) {
$this->log("\n✅ ALL PREREQUISITES SATISFIED");
$this->log("tkr should install and run successfully.");
} else {
$this->log("\n❌ CRITICAL ISSUES FOUND");
$this->log("The following issues must be resolved before installing tkr:");
foreach ($this->errors as $error) {
$this->log("{$error}");
}
}
if (count($this->warnings) > 0) {
$this->log("\n⚠️ WARNINGS:");
foreach ($this->warnings as $warning) {
$this->log("{$warning}");
}
}
$this->log("\n📝 Full log saved to: " . $this->logFile);
$this->log("=== Check completed at " . date('Y-m-d H:i:s') . " ===");
}
/**
* Get array of errors for external use
*/
public function getErrors() {
return $this->errors;
}
/**
* Get array of warnings for external use
*/
public function getWarnings() {
return $this->warnings;
}
}