This commit is contained in:
crazywhalecc
2025-11-30 15:35:04 +08:00
parent f6c818d3c0
commit 14bfb4198a
179 changed files with 19502 additions and 655 deletions

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
class CheckResult
{
public function __construct(private readonly bool $ok, private readonly ?string $message = null, private string $fix_item = '', private array $fix_params = []) {}
public static function fail(string $message, string $fix_item = '', array $fix_params = []): CheckResult
{
return new static(false, $message, $fix_item, $fix_params);
}
public static function ok(?string $message = null): CheckResult
{
return new static(true, $message);
}
public function getMessage(): ?string
{
return $this->message;
}
public function getFixItem(): string
{
return $this->fix_item;
}
public function getFixParams(): array
{
return $this->fix_params;
}
public function isOK(): bool
{
return $this->ok;
}
public function setFixItem(string $fix_item = '', array $fix_params = []): void
{
$this->fix_item = $fix_item;
$this->fix_params = $fix_params;
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Output\OutputInterface;
use ZM\Logger\ConsoleColor;
use function Laravel\Prompts\confirm;
readonly class Doctor
{
public function __construct(private ?OutputInterface $output = null, private int $auto_fix = FIX_POLICY_PROMPT)
{
// debug shows all loaded doctor items
$items = DoctorLoader::getDoctorItems();
$names = array_map(fn ($i) => $i->item_name, array_map(fn ($x) => $x[0], $items));
logger()->debug("Loaded doctor check items:\n\t" . implode("\n\t", $names));
}
/**
* Check all valid check items.
* @return bool true if all checks passed, false otherwise
*/
public function checkAll(bool $interactive = true): bool
{
if ($interactive) {
InteractiveTerm::notice('Starting doctor checks ...');
}
foreach ($this->getValidCheckList() as $check) {
if (!$this->checkItem($check)) {
return false;
}
}
return true;
}
/**
* Check a single check item.
*
* @param CheckItem|string $check The check item to be checked
* @return bool True if the check passed or was fixed, false otherwise
*/
public function checkItem(CheckItem|string $check): bool
{
if (is_string($check)) {
$found = null;
foreach (DoctorLoader::getDoctorItems() as $item) {
if ($item[0]->item_name === $check) {
$found = $item[0];
break;
}
}
if ($found === null) {
$this->output?->writeln("<error>Check item '{$check}' not found.</error>");
return false;
}
$check = $found;
}
$this->output?->write("Checking <comment>{$check->item_name}</comment> ... ");
// call check
$result = call_user_func($check->callback);
if ($result === null) {
$this->output?->writeln('skipped');
return true;
}
if (!$result instanceof CheckResult) {
$this->output?->writeln('<error>Skipped due to invalid return value</error>');
return true;
}
if ($result->isOK()) {
/* @phpstan-ignore-next-line */
$this->output?->writeln($result->getMessage() ?? (string) ConsoleColor::green('✓'));
return true;
}
$this->output?->writeln('<error>' . $result->getMessage() . '</error>');
// if the check item is not fixable, fail immediately
if ($result->getFixItem() === '') {
$this->output?->writeln('This check item can not be fixed automatically !');
return false;
}
// unknown fix item
if (!DoctorLoader::getFixItem($result->getFixItem())) {
$this->output?->writeln("<error>Internal error: Unknown fix item: {$result->getFixItem()}</error>");
return false;
}
// skip fix
if ($this->auto_fix === FIX_POLICY_DIE) {
$this->output?->writeln('<comment>Auto-fix is disabled. Please fix this issue manually.</comment>');
return false;
}
// prompt for fix
if ($this->auto_fix === FIX_POLICY_PROMPT && !confirm('Do you want to try to fix this issue now?')) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
// perform fix
InteractiveTerm::indicateProgress("Fixing {$result->getFixItem()} ... ");
Shell::passthruCallback(function () {
InteractiveTerm::advance();
});
// $this->output?->writeln("Fixing <comment>{$check->item_name}</comment> ... ");
if ($this->emitFix($result->getFixItem(), $result->getFixParams())) {
InteractiveTerm::finish('Fix applied successfully!');
return true;
}
InteractiveTerm::finish('Failed to apply fix!', false);
return false;
}
private function emitFix(string $fix_item, array $fix_item_params = []): bool
{
keyboard_interrupt_register(function () {
$this->output?->writeln('<error>You cancelled fix</error>');
});
try {
return ApplicationContext::invoke(DoctorLoader::getFixItem($fix_item), $fix_item_params);
} catch (SPCException $e) {
$this->output?->writeln('<error>Fix failed: ' . $e->getMessage() . '</error>');
return false;
} catch (\Throwable $e) {
$this->output?->writeln('<error>Fix failed with an unexpected error: ' . $e->getMessage() . '</error>');
return false;
} finally {
keyboard_interrupt_unregister();
}
}
/**
* Get a list of valid check items for current environment.
*/
private function getValidCheckList(): iterable
{
foreach (DoctorLoader::getDoctorItems() as [$item, $optional]) {
/* @var CheckItem $item */
// optional check
if ($optional !== null && !call_user_func($optional)) {
continue; // skip this when the optional check is false
}
// limit_os check
if ($item->limit_os !== null && $item->limit_os !== PHP_OS_FAMILY) {
continue;
}
// skipped items by env
$skip_items = array_filter(explode(',', getenv('SPC_SKIP_DOCTOR_CHECK_ITEMS') ?: ''));
if (in_array($item->item_name, $skip_items)) {
continue; // skip this item
}
yield $item;
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\Util\FileSystem;
class DoctorLoader
{
/**
* @var array<int, array{0: CheckItem, 1: callable}> $doctor_items Loaded doctor check item instances
*/
private static array $doctor_items = [];
/**
* @var array<string, callable> $fix_items loaded doctor fix item instances
*/
private static array $fix_items = [];
/**
* Load doctor check items from PSR-4 directory.
*
* @param string $dir Directory path
* @param string $base_namespace Base namespace for dir's PSR-4 mapping
* @param bool $auto_require Whether to auto-require PHP files (for external plugins not in autoload)
*/
public static function loadFromPsr4Dir(string $dir, string $base_namespace, bool $auto_require = false): void
{
$classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
foreach ($classes as $class) {
self::loadFromClass($class, false);
}
// sort check items by level
usort(self::$doctor_items, function ($a, $b) {
return $a[0]->level > $b[0]->level ? -1 : ($a[0]->level == $b[0]->level ? 0 : 1);
});
}
/**
* Load doctor check items from a class.
*
* @param string $class Class name to load doctor check items from
* @param bool $sort Whether to re-sort Doctor items (default: true)
*/
public static function loadFromClass(string $class, bool $sort = true): void
{
// passthough to all the functions if #[OptionalCheck] is set on class level
$optional_passthrough = null;
$reflection = new \ReflectionClass($class);
$class_instance = $reflection->newInstance();
// parse #[OptionalCheck]
$optional = $reflection->getAttributes(OptionalCheck::class)[0] ?? null;
if ($optional !== null) {
/** @var OptionalCheck $instance */
$instance = $optional->newInstance();
if (is_callable($instance->check)) {
$optional_passthrough = $instance->check;
}
}
$doctor_items = [];
$fix_item_map = [];
// finx check items and fix items from methods in class
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// passthrough for this method if #[OptionalCheck] is set on method level
$optional = $optional_passthrough ?? null;
foreach ($method->getAttributes(OptionalCheck::class) as $method_attr) {
$optional_check = $method_attr->newInstance();
if (is_callable($optional_check->check)) {
$optional = $optional_check->check;
}
}
// parse #[CheckItem]
foreach ($method->getAttributes(CheckItem::class) as $attr) {
/** @var CheckItem $instance */
$instance = $attr->newInstance();
$instance->callback = [$class_instance, $method->getName()];
// put CheckItem instance and optional check callback (or null) to $doctor_items
$doctor_items[] = [$instance, $optional];
}
// parse #[FixItem]
$fix_item = $method->getAttributes(FixItem::class)[0] ?? null;
if ($fix_item !== null) {
$instance = $fix_item->newInstance();
$fix_item_map[$instance->name] = [$class_instance, $method->getName()];
}
}
// add to array
self::$doctor_items = array_merge(self::$doctor_items, $doctor_items);
self::$fix_items = array_merge(self::$fix_items, $fix_item_map);
if ($sort) {
// sort check items by level
usort(self::$doctor_items, function ($a, $b) {
return $a[0]->level > $b[0]->level ? -1 : ($a[0]->level == $b[0]->level ? 0 : 1);
});
}
}
/**
* Returns loaded doctor check items.
*
* @return array<int, array{0: CheckItem, 1: callable}>
*/
public static function getDoctorItems(): array
{
return self::$doctor_items;
}
public static function getFixItem(string $name): ?callable
{
return self::$fix_items[$name] ?? null;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Toolchain\MuslToolchain;
use StaticPHP\Toolchain\ZigToolchain;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\System\LinuxUtil;
#[OptionalCheck([self::class, 'optionalCheck'])]
class LinuxMuslCheck
{
public static function optionalCheck(): bool
{
return getenv('SPC_TOOLCHAIN') === MuslToolchain::class ||
(getenv('SPC_TOOLCHAIN') === ZigToolchain::class && !LinuxUtil::isMuslDist());
}
/** @noinspection PhpUnused */
#[CheckItem('if musl-wrapper is installed', limit_os: 'Linux', level: 800)]
public function checkMusl(): CheckResult
{
$musl_wrapper_lib = sprintf('/lib/ld-musl-%s.so.1', php_uname('m'));
if (file_exists($musl_wrapper_lib) && (file_exists('/usr/local/musl/lib/libc.a') || getenv('SPC_TOOLCHAIN') === ZigToolchain::class)) {
return CheckResult::ok();
}
return CheckResult::fail('musl-wrapper is not installed on your system', 'fix-musl-wrapper');
}
#[CheckItem('if musl-cross-make is installed', limit_os: 'Linux', level: 799)]
public function checkMuslCrossMake(): CheckResult
{
if (getenv('SPC_TOOLCHAIN') === ZigToolchain::class && !LinuxUtil::isMuslDist()) {
return CheckResult::ok();
}
$arch = arch2gnu(php_uname('m'));
$cross_compile_lib = "/usr/local/musl/{$arch}-linux-musl/lib/libc.a";
$cross_compile_gcc = "/usr/local/musl/bin/{$arch}-linux-musl-gcc";
if (file_exists($cross_compile_lib) && file_exists($cross_compile_gcc)) {
return CheckResult::ok();
}
return CheckResult::fail('musl-cross-make is not installed on your system', 'fix-musl-cross-make');
}
#[FixItem('fix-musl-wrapper')]
public function fixMusl(): bool
{
// TODO: implement musl-wrapper installation
// This should:
// 1. Download musl source using Downloader::downloadSource()
// 2. Extract the source using FileSystem::extractSource()
// 3. Apply CVE patches using SourcePatcher::patchFile()
// 4. Build and install musl wrapper
// 5. Add path using putenv instead of editing /etc/profile
return false;
}
#[FixItem('fix-musl-cross-make')]
public function fixMuslCrossMake(): bool
{
// TODO: implement musl-cross-make installation
// This should:
// 1. Install musl-toolchain package using PackageManager::installPackage()
// 2. Copy toolchain files to /usr/local/musl
return false;
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Util\System\MacOSUtil;
class MacOSToolCheck
{
public const array REQUIRED_COMMANDS = [
'curl',
'make',
'bison',
're2c',
'flex',
'pkg-config',
'git',
'autoconf',
'automake',
'tar',
'libtool',
'unzip',
'xz',
'gzip',
'bzip2',
'cmake',
'glibtoolize',
];
#[CheckItem('if homebrew has installed', limit_os: 'Darwin', level: 998)]
public function checkBrew(): ?CheckResult
{
if (($path = MacOSUtil::findCommand('brew')) === null) {
return CheckResult::fail('Homebrew is not installed', 'brew');
}
if ($path !== '/opt/homebrew/bin/brew' && getenv('GNU_ARCH') === 'aarch64') {
return CheckResult::fail('Current homebrew (/usr/local/bin/homebrew) is not installed for M1 Mac, please re-install homebrew in /opt/homebrew/ !');
}
return CheckResult::ok();
}
#[CheckItem('if necessary tools are installed', limit_os: 'Darwin')]
public function checkCliTools(): ?CheckResult
{
$missing = [];
foreach (self::REQUIRED_COMMANDS as $cmd) {
if (MacOSUtil::findCommand($cmd) === null) {
$missing[] = $cmd;
}
}
if (!empty($missing)) {
return CheckResult::fail('missing system commands: ' . implode(', ', $missing), 'build-tools', [$missing]);
}
return CheckResult::ok();
}
#[CheckItem('if bison version is 3.0 or later', limit_os: 'Darwin')]
public function checkBisonVersion(array $command_path = []): ?CheckResult
{
// if the bison command is /usr/bin/bison, it is the system bison that may be too old
if (($bison = MacOSUtil::findCommand('bison', $command_path)) === null) {
return CheckResult::fail('bison is not installed or too old', 'build-tools', [['bison']]);
}
// check version: bison (GNU Bison) x.y(.z)
$version = shell()->execWithResult("{$bison} --version", false);
if (preg_match('/bison \(GNU Bison\) (\d+)\.(\d+)(?:\.(\d+))?/', $version[1][0], $matches)) {
$major = (int) $matches[1];
// major should be 3 or later
if ($major < 3) {
// find homebrew keg-only bison
if ($command_path !== []) {
return CheckResult::fail("Current {$bison} version is too old: " . $matches[0]);
}
return $this->checkBisonVersion(['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin']);
}
return CheckResult::ok($matches[0]);
}
return CheckResult::fail('bison version cannot be determined');
}
#[FixItem('brew')]
public function fixBrew(): bool
{
shell(true)->exec('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');
return true;
}
#[FixItem('build-tools')]
public function fixBuildTools(array $missing): bool
{
$replacement = [
'glibtoolize' => 'libtool',
];
foreach ($missing as $cmd) {
if (isset($replacement[$cmd])) {
$cmd = $replacement[$cmd];
}
shell()->exec('brew install --formula ' . escapeshellarg($cmd));
}
return true;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Util\System\LinuxUtil;
class OSCheck
{
#[CheckItem('if current OS are supported', level: 1000)]
public function checkOS(): ?CheckResult
{
if (!in_array(PHP_OS_FAMILY, ['Darwin', 'Linux', 'Windows'])) {
return CheckResult::fail('Current OS is not supported: ' . PHP_OS_FAMILY);
}
$distro = PHP_OS_FAMILY === 'Linux' ? (' ' . LinuxUtil::getOSRelease()['dist']) : '';
$known_distro = PHP_OS_FAMILY !== 'Linux' || in_array(LinuxUtil::getOSRelease()['dist'], LinuxUtil::getSupportedDistros());
return CheckResult::ok(PHP_OS_FAMILY . ' ' . php_uname('m') . $distro . ', supported' . ($known_distro ? '' : ' (but not tested on this distro)'));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\PkgConfigUtil;
#[OptionalCheck([self::class, 'optionalCheck'])]
class PkgConfigCheck
{
public static function optionalCheck(): bool
{
return SystemTarget::getTargetOS() !== 'Windows';
}
#[CheckItem('if pkg-config is installed or built', level: 800)]
public function check(): CheckResult
{
if (!($pkgconf = PkgConfigUtil::findPkgConfig())) {
return CheckResult::fail('pkg-config is not installed or built', 'install-pkg-config');
}
return CheckResult::ok($pkgconf);
}
#[CheckItem('if pkg-config is functional', level: 799)]
public function checkFunctional(): CheckResult
{
$pkgconf = PkgConfigUtil::findPkgConfig();
[$ret, $output] = shell()->execWithResult("{$pkgconf} --version", false);
if ($ret !== 0) {
return CheckResult::fail('pkg-config is not functional', 'install-pkg-config');
}
return CheckResult::ok(trim($output[0]));
}
#[FixItem('install-pkg-config')]
public function fix(): bool
{
ApplicationContext::set('elephant', true);
$installer = new PackageInstaller(['dl-prefer-binary' => true]);
$installer->addInstallPackage('pkg-config');
$installer->run(false, true);
return true;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Doctor\CheckResult;
class Re2cVersionCheck
{
#[CheckItem('if re2c version >= 1.0.3', limit_os: 'Linux', level: 20)]
#[CheckItem('if re2c version >= 1.0.3', limit_os: 'Darwin', level: 20)]
public function checkRe2cVersion(): ?CheckResult
{
$ver = shell(false)->execWithResult('re2c --version', false);
// match version: re2c X.X(.X)
if ($ver[0] !== 0 || !preg_match('/re2c\s+(\d+\.\d+(\.\d+)?)/', $ver[1][0], $matches)) {
return CheckResult::fail('Failed to get re2c version', 'build-re2c');
}
$version_string = $matches[1];
if (version_compare($version_string, '1.0.3') < 0) {
return CheckResult::fail('re2c version is too low (' . $version_string . ')', 'build-re2c');
}
return CheckResult::ok($version_string);
}
#[FixItem('build-re2c')]
public function buildRe2c(): bool
{
// TODO: implement re2c build process
return false;
}
}