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 () {