$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); } } /** * Check all valid check items. * @return bool true if all checks passed, false otherwise */ public function checkAll(): bool { if ($this->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("Check item '{$check}' not found."); return false; } $check = $found; } $prepend = $this->interactive ? ' - ' : ''; $this->output?->write("{$prepend}Checking {$check->item_name} ... "); // call check $result = call_user_func($check->callback); if ($result === null) { $this->output?->writeln('skipped'); return true; } if (!$result instanceof CheckResult) { $this->output?->writeln('Skipped due to invalid return value'); return true; } if ($result->isOK()) { /* @phpstan-ignore-next-line */ $this->output?->writeln($result->getMessage() ?? (string) ConsoleColor::green('✓')); return true; } $this->output?->writeln('' . $result->getMessage() . ''); // 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("Internal error: Unknown fix item: {$result->getFixItem()}"); return false; } // skip fix if ($this->auto_fix === FIX_POLICY_DIE) { $this->output?->writeln('Auto-fix is disabled. Please fix this issue manually.'); 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('You canceled fix.'); return false; } } // perform fix InteractiveTerm::indicateProgress("Fixing {$result->getFixItem()} ... "); Shell::passthruCallback(function () { InteractiveTerm::advance(); }); // $this->output?->writeln("Fixing {$check->item_name} ... "); 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 = [ 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 = [ 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'; } private function emitFix(string $fix_item, array $fix_item_params = []): bool { keyboard_interrupt_register(function () { $this->output?->writeln('You cancelled fix'); }); try { return ApplicationContext::invoke(DoctorLoader::getFixItem($fix_item), $fix_item_params); } catch (SPCException $e) { $this->output?->writeln('Fix failed: ' . $e->getMessage() . ''); return false; } catch (\Throwable $e) { logger()->debug('Error: ' . $e->getMessage() . " at {$e->getFile()}:{$e->getLine()}\n" . $e->getTraceAsString()); $this->output?->writeln('Fix failed with an unexpected error: ' . $e->getMessage() . ''); 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 /* @phpstan-ignore-next-line */ if (is_callable($optional) && !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; } } }