diff --git a/.gitignore b/.gitignore index 2a351fcd..21bae186 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ packlib_files.txt .php-cs-fixer.cache .phpunit.result.cache +# doctor cache fallback (when ~/.cache/spc/ is not writable) +.spc-doctor.lock + # exclude self-runtime /bin/* !/bin/spc* diff --git a/src/Package/Target/php.php b/src/Package/Target/php.php index ff92ed6a..54d41dc5 100644 --- a/src/Package/Target/php.php +++ b/src/Package/Target/php.php @@ -7,6 +7,7 @@ namespace Package\Target; use Package\Target\php\frankenphp; use Package\Target\php\unix; use Package\Target\php\windows; +use StaticPHP\Artifact\ArtifactCache; use StaticPHP\Attribute\Package\BeforeStage; use StaticPHP\Attribute\Package\Info; use StaticPHP\Attribute\Package\InitPackage; @@ -104,6 +105,24 @@ class php extends TargetPackage throw new WrongUsageException('PHP version file format is malformed, please remove "./source/php-src" dir and download/extract again'); } + /** + * Get PHP version from source archive filename + * + * @return null|string PHP version (e.g., "8.4.0") + */ + public static function getPHPVersionFromArchive(bool $return_null_if_failed = false): ?string + { + $archives = ApplicationContext::get(ArtifactCache::class)->getSourceInfo('php-src'); + $filename = $archives['filename'] ?? ''; + if (!preg_match('/php-(\d+\.\d+\.\d+(?:RC\d+|alpha\d+|beta\d+)?)\.tar\.(?:gz|bz2|xz)/', $filename, $match)) { + if ($return_null_if_failed) { + return null; + } + throw new WrongUsageException('PHP source archive filename format is malformed (got: ' . $filename . ')'); + } + return $match[1]; + } + #[InitPackage] public function init(TargetPackage $package): void { @@ -255,6 +274,7 @@ class php extends TargetPackage 'Build Target' => getenv('SPC_TARGET') ?: '', 'Build Toolchain' => ToolchainManager::getToolchainClass(), 'Build SAPI' => implode(', ', $sapis), + 'PHP Version' => self::getPHPVersion(return_null_if_failed: true) ?? self::getPHPVersionFromArchive(return_null_if_failed: true) ?? 'Unknown', 'Static Extensions (' . count($static_extensions) . ')' => implode(',', array_map(fn ($x) => substr($x->getName(), 4), $static_extensions)), 'Shared Extensions (' . count($shared_extensions) . ')' => implode(',', $shared_extensions), 'Install Packages (' . count($install_packages) . ')' => implode(',', array_map(fn ($x) => $x->getName(), $install_packages)), diff --git a/src/StaticPHP/Command/BaseCommand.php b/src/StaticPHP/Command/BaseCommand.php index 5673e6ba..fcd39e5f 100644 --- a/src/StaticPHP/Command/BaseCommand.php +++ b/src/StaticPHP/Command/BaseCommand.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace StaticPHP\Command; use StaticPHP\DI\ApplicationContext; +use StaticPHP\Doctor\Doctor; use StaticPHP\Exception\ExceptionHandler; use StaticPHP\Exception\SPCException; use Symfony\Component\Console\Command\Command; @@ -118,6 +119,21 @@ abstract class BaseCommand extends Command } } + /** + * Warn the user if doctor has not been run (or is outdated). + * Set SPC_SKIP_DOCTOR_CHECK=1 to suppress. + */ + protected function checkDoctorCache(): void + { + if (getenv('SPC_SKIP_DOCTOR_CHECK') || Doctor::isHealthy()) { + return; + } + $this->output->writeln(''); + $this->output->writeln('[WARNING] Please run `spc doctor` first to verify your build environment.'); + $this->output->writeln(''); + sleep(2); + } + protected function getOption(string $name): mixed { return $this->input->getOption($name); diff --git a/src/StaticPHP/Command/BuildLibsCommand.php b/src/StaticPHP/Command/BuildLibsCommand.php index 63a3ad0f..c18acb0f 100644 --- a/src/StaticPHP/Command/BuildLibsCommand.php +++ b/src/StaticPHP/Command/BuildLibsCommand.php @@ -44,6 +44,8 @@ class BuildLibsCommand extends BaseCommand public function handle(): int { + $this->checkDoctorCache(); + $libs = parse_comma_list($this->input->getArgument('libraries')); $installer = new PackageInstaller($this->input->getOptions()); diff --git a/src/StaticPHP/Command/BuildTargetCommand.php b/src/StaticPHP/Command/BuildTargetCommand.php index 2756070b..8e1ed632 100644 --- a/src/StaticPHP/Command/BuildTargetCommand.php +++ b/src/StaticPHP/Command/BuildTargetCommand.php @@ -37,6 +37,8 @@ class BuildTargetCommand extends BaseCommand public function handle(): int { + $this->checkDoctorCache(); + // resolve legacy options to new options V2CompatLayer::convertOptions($this->input); diff --git a/src/StaticPHP/Command/DoctorCommand.php b/src/StaticPHP/Command/DoctorCommand.php index 6ae6d68a..40303d14 100644 --- a/src/StaticPHP/Command/DoctorCommand.php +++ b/src/StaticPHP/Command/DoctorCommand.php @@ -26,6 +26,7 @@ class DoctorCommand extends BaseCommand }; $doctor = new Doctor($this->output, $fix_policy); if ($doctor->checkAll()) { + Doctor::markPassed(); $this->output->writeln('Doctor check complete !'); return static::SUCCESS; } diff --git a/src/StaticPHP/Command/DownloadCommand.php b/src/StaticPHP/Command/DownloadCommand.php index 270f5538..e021e58b 100644 --- a/src/StaticPHP/Command/DownloadCommand.php +++ b/src/StaticPHP/Command/DownloadCommand.php @@ -56,6 +56,8 @@ class DownloadCommand extends BaseCommand return $this->handleClean(); } + $this->checkDoctorCache(); + $downloader = new ArtifactDownloader(DownloaderOptions::extractFromConsoleOptions($this->input->getOptions())); // arguments diff --git a/src/StaticPHP/Doctor/Doctor.php b/src/StaticPHP/Doctor/Doctor.php index 36db37ae..fc69cc2a 100644 --- a/src/StaticPHP/Doctor/Doctor.php +++ b/src/StaticPHP/Doctor/Doctor.php @@ -9,6 +9,7 @@ use StaticPHP\DI\ApplicationContext; use StaticPHP\Exception\SPCException; use StaticPHP\Registry\DoctorLoader; use StaticPHP\Runtime\Shell\Shell; +use StaticPHP\Runtime\SystemTarget; use StaticPHP\Util\InteractiveTerm; use Symfony\Component\Console\Output\OutputInterface; use ZM\Logger\ConsoleColor; @@ -25,6 +26,29 @@ readonly class Doctor 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 @@ -119,6 +143,30 @@ readonly class Doctor return false; } + private static function getLockPath(): string + { + if (SystemTarget::getTargetOS() === 'Windows') { + $trial_ls = [ + getenv('LOCALAPPDATA') ?: ((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('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 () {