refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ManualUpdater;
interface Checker
{
const ALWAYS = 'always';
const DAILY = 'daily';
const WEEKLY = 'weekly';
const MONTHLY = 'monthly';
const NEVER = 'never';
/**
* Check if the local manual is the latest version.
*/
public function isLatest(): bool;
/**
* Get the latest available version for the configured language/format.
*/
public function getLatest(): string;
/**
* Get the download URL for the latest manual.
*/
public function getDownloadUrl(): string;
}

View File

@@ -0,0 +1,164 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ManualUpdater;
use Psy\Shell;
class GitHubChecker implements Checker
{
const RELEASES_URL = 'https://api.github.com/repos/bobthecow/psysh-manual/releases';
private string $lang;
private string $format;
private ?string $currentVersion;
private ?string $currentLang;
private ?string $latestVersion = null;
private ?string $downloadUrl = null;
/**
* @param string $lang Language code (e.g., 'en')
* @param string $format Format type ('php' or 'sqlite')
* @param string|null $currentVersion Current manual version, or null if not installed
* @param string|null $currentLang Current manual language, or null if not installed
*/
public function __construct(string $lang, string $format, ?string $currentVersion = null, ?string $currentLang = null)
{
$this->lang = $lang;
$this->format = $format;
$this->currentVersion = $currentVersion;
$this->currentLang = $currentLang;
}
public function isLatest(): bool
{
if ($this->currentVersion === null) {
return false;
}
// If language has changed, need to update regardless of version
if ($this->currentLang !== null && $this->currentLang !== $this->lang) {
return false;
}
return \version_compare($this->currentVersion, $this->getLatest(), '>=');
}
public function getLatest(): string
{
if (!isset($this->latestVersion)) {
$this->fetchLatestRelease();
}
return $this->latestVersion;
}
public function getDownloadUrl(): string
{
if (!isset($this->downloadUrl)) {
$this->fetchLatestRelease();
}
return $this->downloadUrl;
}
private function fetchLatestRelease()
{
$context = \stream_context_create([
'http' => [
'user_agent' => 'PsySH/'.Shell::VERSION,
'timeout' => 3.0,
],
]);
\set_error_handler(function () {
// Ignore errors - we'll handle failures below
});
$result = @\file_get_contents(self::RELEASES_URL, false, $context);
\restore_error_handler();
if (!$result) {
throw new \RuntimeException('Unable to fetch manual releases from GitHub');
}
$releases = \json_decode($result, true);
if (!$releases || !\is_array($releases)) {
throw new \RuntimeException('Invalid response from GitHub releases API');
}
// Find the first release with a manifest
foreach ($releases as $release) {
$manifest = $this->fetchManifest($release);
if ($manifest === null) {
continue;
}
// Find our language/format in the manifest
foreach ($manifest['manuals'] as $manual) {
if ($manual['lang'] === $this->lang && $manual['format'] === $this->format) {
$this->latestVersion = $manual['version'];
// Build download URL
$filename = \sprintf('psysh-manual-v%s-%s.tar.gz', $manual['version'], $this->lang);
$this->downloadUrl = $release['assets_url'] ?? null;
// Find the actual asset URL
foreach ($release['assets'] as $asset) {
if ($asset['name'] === $filename) {
$this->downloadUrl = $asset['browser_download_url'];
break;
}
}
return;
}
}
}
throw new \RuntimeException(\sprintf('No manual found for language "%s" in format "%s"', $this->lang, $this->format));
}
/**
* Fetch and parse manifest.json from a release.
*
* @return array|null
*/
private function fetchManifest(array $release): ?array
{
// Find manifest.json in assets
foreach ($release['assets'] as $asset) {
if ($asset['name'] === 'manifest.json') {
$context = \stream_context_create([
'http' => [
'user_agent' => 'PsySH/'.Shell::VERSION,
'timeout' => 3.0,
],
]);
\set_error_handler(function () {
// Ignore errors
});
$manifestContent = @\file_get_contents($asset['browser_download_url'], false, $context);
\restore_error_handler();
if ($manifestContent) {
return \json_decode($manifestContent, true);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ManualUpdater;
class Installer
{
private string $dataDir;
private string $format;
/**
* @param string $dataDir Data directory where manual will be installed
* @param string $format Format type ('php' or 'sqlite')
*/
public function __construct(string $dataDir, string $format)
{
$this->dataDir = $dataDir;
$this->format = $format;
}
/**
* Check if the data directory is writable.
*/
public function isDataDirWritable(): bool
{
return \is_dir($this->dataDir) && \is_writable($this->dataDir);
}
/**
* Extract and install the manual from a downloaded tarball.
*
* @param string $tarballPath Path to the downloaded .tar.gz file
*
* @return bool True on success
*/
public function install(string $tarballPath): bool
{
if (!\file_exists($tarballPath)) {
return false;
}
// Create temp directory for extraction
$tempDir = \sys_get_temp_dir().'/psysh-manual-'.\uniqid();
if (!\mkdir($tempDir)) {
return false;
}
try {
// Extract tarball
$phar = new \PharData($tarballPath);
$phar->extractTo($tempDir);
// Determine the manual filename
$manualFilename = $this->format === 'php' ? 'php_manual.php' : 'php_manual.sqlite';
$extractedFile = $tempDir.'/'.$manualFilename;
if (!\file_exists($extractedFile)) {
return false;
}
// Move to data directory (overwrites existing)
$success = \rename($extractedFile, $this->getInstallPath());
return $success;
} finally {
// Clean up temp directory
$this->removeDirectory($tempDir);
}
}
/**
* Get the path where the manual will be installed.
*/
public function getInstallPath(): string
{
$manualFilename = $this->format === 'php' ? 'php_manual.php' : 'php_manual.sqlite';
return $this->dataDir.'/'.$manualFilename;
}
/**
* Recursively remove a directory.
*/
private function removeDirectory(string $dir)
{
if (!\is_dir($dir)) {
return;
}
$files = \array_diff(\scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir.'/'.$file;
\is_dir($path) ? $this->removeDirectory($path) : \unlink($path);
}
\rmdir($dir);
}
}

View File

@@ -0,0 +1,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ManualUpdater;
/**
* An interval-based manual update checker.
*
* Caches update checks and only checks for updates at the configured interval.
*/
class IntervalChecker implements Checker
{
private Checker $checker;
private string $cacheFile;
private string $interval;
private ?array $cached = null;
public function __construct(Checker $checker, string $cacheFile, string $interval)
{
$this->checker = $checker;
$this->cacheFile = $cacheFile;
$this->interval = $interval;
}
public function isLatest(): bool
{
$this->loadCache();
// If we have a recent check, use the cached result
if ($this->isCacheValid()) {
return $this->cached['is_latest'] ?? true;
}
// Otherwise check now and cache the result
$isLatest = $this->checker->isLatest();
$this->updateCache($isLatest);
return $isLatest;
}
public function getLatest(): string
{
$this->loadCache();
// If we have a recent check, use the cached version
if ($this->isCacheValid() && isset($this->cached['latest_version'])) {
return $this->cached['latest_version'];
}
// Otherwise fetch now and cache the result
$latest = $this->checker->getLatest();
$this->updateCache(null, $latest);
return $latest;
}
public function getDownloadUrl(): string
{
// Always delegate to the underlying checker
// (URL might change between checks)
return $this->checker->getDownloadUrl();
}
private function loadCache()
{
if ($this->cached !== null) {
return;
}
$content = @\file_get_contents($this->cacheFile);
if ($content) {
$this->cached = \json_decode($content, true) ?: [];
} else {
$this->cached = [];
}
}
private function isCacheValid(): bool
{
if (!isset($this->cached['last_check'])) {
return false;
}
try {
$now = new \DateTime();
$lastCheck = new \DateTime($this->cached['last_check']);
return $lastCheck >= $now->sub($this->getDateInterval());
} catch (\Exception $e) {
return false;
}
}
/**
* @throws \RuntimeException if interval is not supported
*/
private function getDateInterval(): \DateInterval
{
switch ($this->interval) {
case Checker::DAILY:
return new \DateInterval('P1D');
case Checker::WEEKLY:
return new \DateInterval('P1W');
case Checker::MONTHLY:
return new \DateInterval('P1M');
}
throw new \RuntimeException('Invalid interval configured');
}
private function updateCache(?bool $isLatest = null, ?string $latestVersion = null)
{
$this->loadCache();
// Update cache data
$this->cached['last_check'] = \date(\DATE_ATOM);
if ($isLatest !== null) {
$this->cached['is_latest'] = $isLatest;
}
if ($latestVersion !== null) {
$this->cached['latest_version'] = $latestVersion;
}
// Write to file
@\file_put_contents($this->cacheFile, \json_encode($this->cached));
}
}

View File

@@ -0,0 +1,341 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ManualUpdater;
use Psy\ConfigPaths;
use Psy\Configuration;
use Psy\Exception\ErrorException;
use Psy\Exception\InvalidManualException;
use Psy\Manual\V2Manual;
use Psy\Manual\V3Manual;
use Psy\VersionUpdater\Downloader;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* Manual update command.
*
* If a new manual version is available, this command will download and install it.
*/
class ManualUpdate
{
const SUCCESS = 0;
const FAILURE = 1;
/** @var array{checker: Checker, installer: Installer}[] */
private array $updates;
private ?Downloader $downloader = null;
/**
* @param array{checker: Checker, installer: Installer} ...$updates Update configuration(s)
*/
public function __construct(array ...$updates)
{
$this->updates = $updates;
}
/**
* Create a ManualUpdate instance from Configuration and command-line input.
*
* @param Configuration $config Configuration instance
* @param InputInterface $input Input interface
* @param OutputInterface $output Output interface
*
* @return self
*/
public static function fromConfig(Configuration $config, InputInterface $input, OutputInterface $output): self
{
$lang = $input->getOption('update-manual') ?: null;
// Clear the manual update cache when explicitly running --update-manual
$cacheFile = $config->getManualUpdateCheckCacheFile();
if ($cacheFile && \file_exists($cacheFile)) {
@\unlink($cacheFile);
}
// Get current manual language before potentially deleting files
$currentLang = null;
$removedInvalidSqlite = false;
$manualFile = $config->getManualDbFile();
if ($manualFile && \file_exists($manualFile)) {
try {
$manual = $config->getManual();
if ($manual) {
$currentMeta = $manual->getMeta();
$currentLang = $currentMeta['lang'] ?? null;
}
} catch (InvalidManualException $e) {
$removedInvalidSqlite = \substr($e->getManualFile(), -7) === '.sqlite';
self::handleInvalidManual($e, $input, $output);
}
}
$dataDir = $config->getManualInstallDir();
if ($dataDir === false) {
throw new \RuntimeException('Unable to find a writable data directory for manual installation');
}
$phpManualPath = $dataDir.'/php_manual.php';
$sqliteManualPath = $dataDir.'/php_manual.sqlite';
$formats = self::getFormatsToUpdate(
$input,
$output,
\file_exists($phpManualPath),
\file_exists($sqliteManualPath),
$removedInvalidSqlite,
$sqliteManualPath
);
// Build update configurations for selected formats
$checkerLang = $lang ?: $currentLang ?: 'en';
$updates = [];
foreach ($formats as $format) {
$path = $format === 'php' ? $phpManualPath : $sqliteManualPath;
$meta = self::getManualMeta($path);
$updates[] = [
'checker' => new GitHubChecker($checkerLang, $format, $meta['version'] ?? null, $meta['lang'] ?? null),
'installer' => new Installer($dataDir, $format),
];
}
return new self(...$updates);
}
/**
* Allow the downloader to be injected for testing.
*
* @return void
*/
public function setDownloader(Downloader $downloader)
{
$this->downloader = $downloader;
}
/**
* Get the currently set Downloader or create one based on the capabilities of the php environment.
*
* @throws ErrorException if a downloader cannot be created for the php environment
*/
private function getDownloader(): Downloader
{
if (!isset($this->downloader)) {
return Downloader\Factory::getDownloader();
}
return $this->downloader;
}
/**
* Update the manual installation.
*/
public function run(InputInterface $input, OutputInterface $output): int
{
foreach ($this->updates as $update) {
if (!$update['installer']->isDataDirWritable()) {
$output->writeln('<error>Data directory is not writable.</error>');
return self::FAILURE;
}
}
$downloader = $this->getDownloader();
$downloader->setTempDir(\sys_get_temp_dir());
$installed = [];
// Download and install each format
foreach ($this->updates as $update) {
$checker = $update['checker'];
$installer = $update['installer'];
if ($checker->isLatest()) {
continue;
}
$latestVersion = $checker->getLatest();
$downloadUrl = $checker->getDownloadUrl();
$output->write("Downloading manual v{$latestVersion}...");
try {
$downloaded = $downloader->download($downloadUrl);
} catch (ErrorException $e) {
$output->write(' <error>Failed.</error>');
$output->writeln(\sprintf('<error>%s</error>', $e->getMessage()));
$downloader->cleanup();
return self::FAILURE;
}
if (!$downloaded) {
$output->writeln(' <error>Download failed.</error>');
$downloader->cleanup();
return self::FAILURE;
}
$output->write(' <info>OK</info>'.\PHP_EOL);
$downloadedFile = $downloader->getFilename();
if (!$installer->install($downloadedFile)) {
$downloader->cleanup();
$output->writeln('<error>Failed to install manual.</error>');
return self::FAILURE;
}
$installed[] = [$installer->getInstallPath(), $latestVersion];
$downloader->cleanup();
}
if (empty($installed)) {
$output->writeln('<info>Manual is up-to-date.</info>');
} else {
foreach ($installed as [$installPath, $version]) {
$prettyPath = ConfigPaths::prettyPath($installPath);
$output->writeln("Installed manual v{$version} to <info>{$prettyPath}</info>");
}
}
return self::SUCCESS;
}
/**
* Handle an invalid manual file by prompting the user to remove it.
*
* @param InvalidManualException $e The exception containing invalid manual details
* @param InputInterface $input Input interface
* @param OutputInterface $output Output interface
*
* @throws \RuntimeException if user declines to remove the file or removal fails
*/
private static function handleInvalidManual(InvalidManualException $e, InputInterface $input, OutputInterface $output): void
{
$prettyPath = ConfigPaths::prettyPath($e->getManualFile());
$output->writeln(\sprintf('<error>Invalid manual file detected:</error> <info>%s</info>', $prettyPath));
$output->writeln('');
$helper = new QuestionHelper();
$question = new ConfirmationQuestion('Remove this file and continue? [Y/n] ', true);
if (!$helper->ask($input, $output, $question)) {
throw new \RuntimeException('Manual update cancelled.');
}
if (!\unlink($e->getManualFile())) {
throw new \RuntimeException(\sprintf('Failed to remove file: %s', $prettyPath));
}
$output->writeln('<info>Invalid manual file removed.</info>');
$output->writeln('');
}
/**
* Prompt user to download PHP format manual when they have/had legacy SQLite.
*
* @param InputInterface $input Input interface
* @param OutputInterface $output Output interface
* @param string $manualFile Path to current/former SQLite manual file
* @param bool $wasRemoved Whether the file was already removed
*
* @return bool True if user wants to download PHP format
*/
private static function promptMigrateToV3(InputInterface $input, OutputInterface $output, string $manualFile, bool $wasRemoved): bool
{
$prettyPath = ConfigPaths::prettyPath($manualFile);
$verb = $wasRemoved ? 'had' : 'have';
$output->writeln(\sprintf('You %s a legacy SQLite manual: <info>%s</info>', $verb, $prettyPath));
$output->writeln('');
$helper = new QuestionHelper();
$question = new ConfirmationQuestion('Download the current manual format? [Y/n] ', true);
return $helper->ask($input, $output, $question);
}
/**
* Determine which manual formats should be updated.
*
* @param InputInterface $input Input interface
* @param OutputInterface $output Output interface
* @param bool $hasPhpManual Whether PHP manual exists
* @param bool $hasSqliteManual Whether SQLite manual exists
* @param bool $removedInvalidSqlite Whether we just removed an invalid SQLite manual
* @param string $sqliteManualPath Path to SQLite manual file
*
* @return string[] Array of format names to update ('php', 'sqlite')
*/
private static function getFormatsToUpdate(
InputInterface $input,
OutputInterface $output,
bool $hasPhpManual,
bool $hasSqliteManual,
bool $removedInvalidSqlite,
string $sqliteManualPath
): array {
// Only SQLite exists (or just removed invalid SQLite): offer to add PHP format
if (!$hasPhpManual && ($hasSqliteManual || $removedInvalidSqlite)) {
if (self::promptMigrateToV3($input, $output, $sqliteManualPath, $removedInvalidSqlite)) {
return ['php', 'sqlite'];
}
return ['sqlite'];
}
// PHP exists, or neither exist: default to PHP, and include SQLite if it exists
$formats = ['php'];
if ($hasSqliteManual) {
$formats[] = 'sqlite';
}
return $formats;
}
/**
* Get manual metadata from a file.
*
* @param string $path Path to manual file
*
* @return array|null Metadata array with 'version' and 'lang' keys, or null if unavailable
*/
private static function getManualMeta(string $path): ?array
{
if (!\file_exists($path)) {
return null;
}
try {
if (\substr($path, -4) === '.php') {
$manual = new V3Manual($path);
return $manual->getMeta();
}
if (\substr($path, -7) === '.sqlite') {
$pdo = new \PDO('sqlite:'.$path);
$manual = new V2Manual($pdo);
return $manual->getMeta();
}
} catch (\Exception $e) {
// Ignore errors reading manual metadata
}
return null;
}
}