Files
static-php-cli/src/StaticPHP/Doctor/Doctor.php

226 lines
8.7 KiB
PHP
Raw Normal View History

2025-11-30 15:35:04 +08:00
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use StaticPHP\Registry\DoctorLoader;
2025-11-30 15:35:04 +08:00
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
2025-11-30 15:35:04 +08:00
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
2025-11-30 15:35:04 +08:00
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
2025-11-30 15:35:04 +08:00
use ZM\Logger\ConsoleColor;
readonly class Doctor
{
public function __construct(private ?OutputInterface $output = null, private int $auto_fix = FIX_POLICY_PROMPT, public readonly bool $interactive = true)
2025-11-30 15:35:04 +08:00
{
// 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));
}
/**
* Returns true if doctor was previously passed with the current SPC version.
*/
public static function isHealthy(): bool
{
$lock = self::getLockPath();
return file_exists($lock) && trim((string) @file_get_contents($lock)) === \StaticPHP\ConsoleApplication::VERSION;
}
/**
* Write current SPC version to the lock file, marking doctor as passed.
*/
public static function markPassed(): void
{
$primary = self::getLockPath();
if (!is_dir(dirname($primary))) {
@mkdir(dirname($primary), 0755, true);
}
if (@file_put_contents($primary, \StaticPHP\ConsoleApplication::VERSION) === false) {
@file_put_contents((getcwd() ?: '.') . DIRECTORY_SEPARATOR . '.spc-doctor.lock', \StaticPHP\ConsoleApplication::VERSION);
}
}
2025-11-30 15:35:04 +08:00
/**
* Check all valid check items.
* @return bool true if all checks passed, false otherwise
*/
public function checkAll(): bool
2025-11-30 15:35:04 +08:00
{
if ($this->interactive) {
2025-11-30 15:35:04 +08:00
InteractiveTerm::notice('Starting doctor checks ...');
}
foreach ($this->getValidCheckList() as $check) {
if (!$this->checkItem($check)) {
2025-11-30 15:35:04 +08:00
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
2025-11-30 15:35:04 +08:00
{
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;
}
$prepend = $this->interactive ? ' - ' : '';
$this->output?->write("{$prepend}Checking <comment>{$check->item_name}</comment> ... ");
2025-11-30 15:35:04 +08:00
// 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) {
$helper = new QuestionHelper();
$input = ApplicationContext::has(InputInterface::class) ? ApplicationContext::get(InputInterface::class) : new ArrayInput([]);
$output = ApplicationContext::has(OutputInterface::class) ? ApplicationContext::get(OutputInterface::class) : $this->output ?? new ConsoleOutput();
if (!$helper->ask($input, $output, new ConfirmationQuestion('Do you want to try to fix this issue now? [Y/n] ', true))) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
2025-11-30 15:35:04 +08:00
}
// 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 static function getLockPath(): string
{
if (SystemTarget::getTargetOS() === 'Windows') {
$trial_ls = [
2026-03-20 16:16:09 +08:00
getenv('LOCALAPPDATA') ?
(getenv('LOCALAPPDATA') . '\.spc-doctor.lock') :
(((getenv('USERPROFILE') ?: 'C:\Users\Default') . '\AppData\Local') . '\.spc-doctor.lock'),
sys_get_temp_dir() . '\.spc-doctor.lock',
WORKING_DIR . '\.spc-doctor.lock',
];
} else {
$trial_ls = [
2026-03-20 16:16:09 +08:00
getenv('XDG_CACHE_HOME') ?
(getenv('XDG_CACHE_HOME') . '/.spc-doctor.lock')
: (((getenv('HOME') ?: '/tmp') . '/.cache') . '/.spc-doctor.lock'),
sys_get_temp_dir() . '/.spc-doctor.lock',
WORKING_DIR . '/.spc-doctor.lock',
];
}
foreach ($trial_ls as $path) {
if (is_writable(dirname($path))) {
return $path;
}
}
// fallback to current directory
return WORKING_DIR . DIRECTORY_SEPARATOR . '.spc-doctor.lock';
}
2025-11-30 15:35:04 +08:00
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) {
logger()->debug('Error: ' . $e->getMessage() . " at {$e->getFile()}:{$e->getLine()}\n" . $e->getTraceAsString());
2025-11-30 15:35:04 +08:00
$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
2026-02-03 13:02:43 +08:00
/* @phpstan-ignore-next-line */
if (is_callable($optional) && !call_user_func($optional)) {
2025-11-30 15:35:04 +08:00
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;
}
}
}