From 5d347adbcf3881b1d1cff8201a3aa56b085f3601 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Wed, 15 Mar 2023 20:40:49 +0800 Subject: [PATCH] initial framework commit --- .gitignore | 13 + .php-cs-fixer.php | 69 ++++ bin/static-php-cli | 16 + captainhook.json | 47 +++ composer.json | 55 +++ phpstan.neon | 12 + src/SPC/ConsoleApplication.php | 52 +++ src/SPC/command/BaseCommand.php | 59 +++ src/SPC/exception/DownloaderException.php | 9 + src/SPC/exception/ExceptionHandler.php | 53 +++ src/SPC/exception/FileSystemException.php | 9 + .../exception/InvalidArgumentException.php | 9 + src/SPC/exception/RuntimeException.php | 9 + src/SPC/store/Config.php | 126 ++++++ src/SPC/store/Downloader.php | 328 +++++++++++++++ src/SPC/store/FileSystem.php | 379 ++++++++++++++++++ src/SPC/util/ConfigValidator.php | 71 ++++ src/globals/defines.php | 43 ++ src/globals/functions.php | 97 +++++ 19 files changed, 1456 insertions(+) create mode 100644 .php-cs-fixer.php create mode 100755 bin/static-php-cli create mode 100644 captainhook.json create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 src/SPC/ConsoleApplication.php create mode 100644 src/SPC/command/BaseCommand.php create mode 100644 src/SPC/exception/DownloaderException.php create mode 100644 src/SPC/exception/ExceptionHandler.php create mode 100644 src/SPC/exception/FileSystemException.php create mode 100644 src/SPC/exception/InvalidArgumentException.php create mode 100644 src/SPC/exception/RuntimeException.php create mode 100644 src/SPC/store/Config.php create mode 100644 src/SPC/store/Downloader.php create mode 100644 src/SPC/store/FileSystem.php create mode 100644 src/SPC/util/ConfigValidator.php create mode 100644 src/globals/defines.php create mode 100644 src/globals/functions.php diff --git a/.gitignore b/.gitignore index ceb7d1f8..bc7b4b77 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,16 @@ runtime/ docker/libraries/ docker/extensions/ docker/source/ + +# Composer file +composer.lock +/vendor/ + +# source extract directory +/source/ + +# source download directory +/downloads/ + +# source build root directory +/buildroot/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 00000000..e3778666 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,69 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@Symfony' => true, + '@PhpCsFixer' => true, + 'array_syntax' => [ + 'syntax' => 'short', + ], + 'list_syntax' => [ + 'syntax' => 'short', + ], + 'concat_space' => [ + 'spacing' => 'one', + ], + 'blank_line_before_statement' => [ + 'statements' => [ + 'declare', + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'class', + 'function', + 'const', + ], + 'sort_algorithm' => 'alpha', + ], + 'single_line_comment_style' => [ + 'comment_types' => [ + ], + ], + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + ], + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'constant_case' => [ + 'case' => 'lower', + ], + 'class_attributes_separation' => true, + 'combine_consecutive_unsets' => true, + 'declare_strict_types' => true, + 'linebreak_after_opening_tag' => true, + 'lowercase_static_reference' => true, + 'no_useless_else' => true, + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => false, + 'not_operator_with_space' => false, + 'ordered_class_elements' => true, + 'php_unit_strict' => false, + 'phpdoc_separation' => false, + 'single_quote' => true, + 'standardize_not_equals' => true, + 'multiline_comment_opening_closing' => true, + 'phpdoc_summary' => false, + 'php_unit_test_class_requires_covers' => false, + 'phpdoc_var_without_name' => false, + ]) + ->setFinder( + PhpCsFixer\Finder::create()->in(__DIR__ . '/src') + ); diff --git a/bin/static-php-cli b/bin/static-php-cli new file mode 100755 index 00000000..1c012bf9 --- /dev/null +++ b/bin/static-php-cli @@ -0,0 +1,16 @@ +#!php +run(); +} catch (Exception $e) { + \SPC\exception\ExceptionHandler::getInstance()->handle($e); +} diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 00000000..d352ee62 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,47 @@ +{ + "pre-push": { + "enabled": true, + "actions": [ + { + "action": "composer analyse" + }, + { + "action": "composer test" + } + ] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "composer cs-fix -- --config=.php-cs-fixer.php --dry-run --diff {$STAGED_FILES|of-type:php}", + "conditions": [ + { + "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType", + "args": ["php"] + } + ] + } + ] + }, + "post-change": { + "enabled": true, + "actions": [ + { + "action": "composer install", + "options": [], + "conditions": [ + { + "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileChanged\\Any", + "args": [ + [ + "composer.json", + "composer.lock" + ] + ] + } + ] + } + ] + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..845a6773 --- /dev/null +++ b/composer.json @@ -0,0 +1,55 @@ +{ + "require": { + "php": ">= 8.0", + "ext-tokenizer": "*", + "ext-iconv": "*", + "symfony/console": "^6 || ^5 || ^4", + "zhamao/logger": "^1.0", + "crazywhalecc/cli-helper": "^0.1.0", + "nunomaduro/collision": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2 != 3.7.0", + "phpstan/phpstan": "^1.1", + "captainhook/captainhook": "^5.10", + "captainhook/plugin-composer": "^5.3" + }, + "autoload": { + "psr-4": { + "SPC\\": "src/SPC" + }, + "files": [ + "src/globals/defines.php", + "src/globals/functions.php" + ] + }, + "extra": { + "hooks": { + "post-merge": "composer install", + "pre-commit": [ + "echo committing as $(git config user.name)", + "composer cs-fix -- --diff" + ], + "pre-push": [ + "composer cs-fix -- --dry-run --diff", + "composer analyse" + ] + } + }, + "bin": [ + "bin/static-php-cli" + ], + "scripts": { + "analyse": "phpstan analyse --memory-limit 300M", + "cs-fix": "php-cs-fixer fix", + "test": "bin/phpunit --no-coverage" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true, + "captainhook/plugin-composer": true + }, + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..3f0ab052 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + reportUnmatchedIgnoredErrors: false + level: 4 + paths: + - ./src/ + ignoreErrors: + - '#Constant .* not found#' + - '#Unsafe usage of new static#' + - '#class Fiber#' + - '#Attribute class JetBrains\\PhpStorm\\ArrayShape does not exist#' + dynamicConstantNames: + - PHP_OS_FAMILY \ No newline at end of file diff --git a/src/SPC/ConsoleApplication.php b/src/SPC/ConsoleApplication.php new file mode 100644 index 00000000..bee805b2 --- /dev/null +++ b/src/SPC/ConsoleApplication.php @@ -0,0 +1,52 @@ +setCatchExceptions(file_exists(ROOT_DIR . '/.prod') || !in_array('--debug', $argv)); + + // 通过扫描目录 src/static-php-cli/command/ 添加子命令 + $commands = FileSystem::getClassesPsr4(ROOT_DIR . '/src/SPC/command', 'SPC\\command'); + $this->addCommands(array_map(function ($x) { return new $x(); }, array_filter($commands, function ($y) { + if (is_a($y, DeployCommand::class, true) && (class_exists('\\Phar') && \Phar::running() || !class_exists('\\Phar'))) { + return false; + } + $reflection = new \ReflectionClass($y); + return !$reflection->isAbstract() && !$reflection->isInterface(); + }))); + } + + /** + * 重载以去除一些不必要的默认命令 + */ + protected function getDefaultCommands(): array + { + return [new HelpCommand(), new ListCommand()]; + } +} diff --git a/src/SPC/command/BaseCommand.php b/src/SPC/command/BaseCommand.php new file mode 100644 index 00000000..47671b6a --- /dev/null +++ b/src/SPC/command/BaseCommand.php @@ -0,0 +1,59 @@ +addOption('debug', null, null, 'Enable debug mode'); + } + + public function initialize(InputInterface $input, OutputInterface $output) + { + // 注册全局错误处理器 + set_error_handler(static function ($error_no, $error_msg, $error_file, $error_line) { + $tips = [ + E_WARNING => ['PHP Warning: ', 'warning'], + E_NOTICE => ['PHP Notice: ', 'notice'], + E_USER_ERROR => ['PHP Error: ', 'error'], + E_USER_WARNING => ['PHP Warning: ', 'warning'], + E_USER_NOTICE => ['PHP Notice: ', 'notice'], + E_STRICT => ['PHP Strict: ', 'notice'], + E_RECOVERABLE_ERROR => ['PHP Recoverable Error: ', 'error'], + E_DEPRECATED => ['PHP Deprecated: ', 'notice'], + E_USER_DEPRECATED => ['PHP User Deprecated: ', 'notice'], + ]; + $level_tip = $tips[$error_no] ?? ['PHP Unknown: ', 'error']; + $error = $level_tip[0] . $error_msg . ' in ' . $error_file . ' on ' . $error_line; + logger()->{$level_tip[1]}($error); + // 如果 return false 则错误会继续递交给 PHP 标准错误处理 + return true; + }, E_ALL | E_STRICT); + if ($input->getOption('debug')) { + global $ob_logger; + $ob_logger = new ConsoleLogger(LogLevel::DEBUG); + define('DEBUG_MODE', true); + } + $version = ConsoleApplication::VERSION; + if (!isset($this->no_motd)) { + echo " _ _ _ _ + ___| |_ __ _| |_(_) ___ _ __ | |__ _ __ +/ __| __/ _` | __| |/ __|____| '_ \\| '_ \\| '_ \\ +\\__ \\ || (_| | |_| | (_|_____| |_) | | | | |_) | +|___/\\__\\__,_|\\__|_|\\___| | .__/|_| |_| .__/ v{$version} + |_| |_| +"; + } + } +} diff --git a/src/SPC/exception/DownloaderException.php b/src/SPC/exception/DownloaderException.php new file mode 100644 index 00000000..41a1d423 --- /dev/null +++ b/src/SPC/exception/DownloaderException.php @@ -0,0 +1,9 @@ +whoops = new $whoops_class(); + $this->whoops->allowQuit(false); + $this->whoops->writeToOutput(false); + $this->whoops->pushHandler(new $collision_class()); + $this->whoops->register(); + } + } + + public static function getInstance(): ExceptionHandler + { + if (self::$obj === null) { + self::$obj = new self(); + } + return self::$obj; + } + + public function getWhoops() + { + return $this->whoops; + } + + /** + * 处理异常 + */ + public function handle(\Throwable $e): void + { + if (is_null($this->whoops)) { + logger()->error('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' at ' . $e->getFile() . '(' . $e->getLine() . ')'); + logger()->error($e->getTraceAsString()); + return; + } + + $this->whoops->handleException($e); + } +} diff --git a/src/SPC/exception/FileSystemException.php b/src/SPC/exception/FileSystemException.php new file mode 100644 index 00000000..ca72968e --- /dev/null +++ b/src/SPC/exception/FileSystemException.php @@ -0,0 +1,9 @@ + ['-windows', '-win', ''], + 'Darwin' => ['-macos', '-unix', ''], + 'Linux' => ['-linux', '-unix', ''], + default => throw new RuntimeException('OS ' . PHP_OS_FAMILY . ' is not supported'), + }; + foreach ($m_key as $v) { + if (isset(self::$lib[$name][$key . $v])) { + return self::$lib[$name][$key . $v]; + } + } + return $default; + } + if ($key !== null) { + return self::$lib[$name][$key] ?? $default; + } + return self::$lib[$name]; + } + + /** + * @throws FileSystemException + */ + public static function getLibs(): array + { + if (self::$lib === null) { + self::$lib = FileSystem::loadConfigArray('lib'); + } + return self::$lib; + } + + /** + * @throws FileSystemException + * @throws RuntimeException + */ + public static function getExt(string $name, ?string $key = null, mixed $default = null) + { + if (self::$ext === null) { + self::$ext = FileSystem::loadConfigArray('ext'); + } + if (!isset(self::$ext[$name])) { + throw new RuntimeException('ext [' . $name . '] is not supported yet for get'); + } + $supported_sys_based = ['lib-depends', 'lib-suggests', 'ext-depends', 'ext-suggests', 'arg-type']; + if ($key !== null && in_array($key, $supported_sys_based)) { + $m_key = match (PHP_OS_FAMILY) { + 'Windows' => ['-windows', '-win', ''], + 'Darwin' => ['-macos', '-unix', ''], + 'Linux' => ['-linux', '-unix', ''], + default => throw new RuntimeException('OS ' . PHP_OS_FAMILY . ' is not supported'), + }; + foreach ($m_key as $v) { + if (isset(self::$ext[$name][$key . $v])) { + return self::$ext[$name][$key . $v]; + } + } + return $default; + } + if ($key !== null) { + return self::$ext[$name][$key] ?? $default; + } + return self::$ext[$name]; + } + + /** + * @throws FileSystemException + */ + public static function getExts(): array + { + if (self::$ext === null) { + self::$ext = FileSystem::loadConfigArray('ext'); + } + return self::$ext; + } +} diff --git a/src/SPC/store/Downloader.php b/src/SPC/store/Downloader.php new file mode 100644 index 00000000..6eb1e902 --- /dev/null +++ b/src/SPC/store/Downloader.php @@ -0,0 +1,328 @@ + 返回下载 url 链接和文件名 + * @throws DownloaderException + */ + public static function getLatestBitbucketTag(string $name, array $source): array + { + logger()->debug("finding {$name} source from bitbucket tag"); + $data = json_decode(self::curlExec( + url: "https://api.bitbucket.org/2.0/repositories/{$source['repo']}/refs/tags" + ), true); + $ver = $data['values'][0]['name']; + if (!$ver) { + throw new DownloaderException("failed to find {$name} bitbucket source"); + } + $url = "https://bitbucket.org/{$source['repo']}/get/{$ver}.tar.gz"; + $headers = self::curlExec( + url: $url, + method: 'HEAD' + ); + preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?.+\.tar\.gz)\1/im', $headers, $matches); + if ($matches) { + $filename = $matches['filename']; + } else { + $filename = "{$name}-{$data['tag_name']}.tar.gz"; + } + + return [$url, $filename]; + } + + /** + * 获取 GitHub 最新的打包地址和文件名 + * + * @param string $name 包名称 + * @param array $source 源信息 + * @throws DownloaderException + */ + public static function getLatestGithubTarball(string $name, array $source, string $type = 'releases'): array + { + logger()->debug("finding {$name} source from github {$type} tarball"); + $data = json_decode(self::curlExec( + url: "https://api.github.com/repos/{$source['repo']}/{$type}", + hooks: [[CurlHook::class, 'setupGithubToken']] + ), true); + $url = $data[0]['tarball_url']; + if (!$url) { + throw new DownloaderException("failed to find {$name} source"); + } + $headers = self::curlExec( + url: $url, + method: 'HEAD', + hooks: [[CurlHook::class, 'setupGithubToken']], + ); + preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?.+\.tar\.gz)\1/im', $headers, $matches); + if ($matches) { + $filename = $matches['filename']; + } else { + $filename = "{$name}-" . ($type === 'releases' ? $data['tag_name'] : $data['name']) . '.tar.gz'; + } + + return [$url, $filename]; + } + + /** + * 获取 GitHub 最新的 Release 下载信息 + * + * @param string $name 资源名 + * @param array $source 资源的元信息,包含字段 repo、match + * @throws DownloaderException + */ + public static function getLatestGithubRelease(string $name, array $source): array + { + logger()->debug("finding {$name} source from github releases assests"); + $data = json_decode(self::curlExec( + url: "https://api.github.com/repos/{$source['repo']}/releases", + hooks: [[CurlHook::class, 'setupGithubToken']], + ), true); + $url = null; + foreach ($data[0]['assets'] as $asset) { + if (preg_match('|' . $source['match'] . '|', $asset['name'])) { + $url = $asset['browser_download_url']; + break; + } + } + if (!$url) { + throw new DownloaderException("failed to find {$name} source"); + } + $filename = basename($url); + + return [$url, $filename]; + } + + /** + * 获取文件列表的资源链接和名称 + * + * @param string $name 资源名称 + * @param array $source 资源元信息,包含 url、regex + * @throws DownloaderException + */ + public static function getFromFileList(string $name, array $source): array + { + logger()->debug("finding {$name} source from file list"); + $page = self::curlExec($source['url']); + preg_match_all($source['regex'], $page, $matches); + if (!$matches) { + throw new DownloaderException("Failed to get {$name} version"); + } + $versions = []; + foreach ($matches['version'] as $i => $version) { + $lowerVersion = strtolower($version); + foreach ([ + 'alpha', + 'beta', + 'rc', + 'pre', + 'nightly', + 'snapshot', + 'dev', + ] as $betaVersion) { + if (str_contains($lowerVersion, $betaVersion)) { + continue 2; + } + } + $versions[$version] = $matches['file'][$i]; + } + uksort($versions, 'version_compare'); + + return [$source['url'] . end($versions), end($versions)]; + } + + /** + * 通过链接下载资源到本地并解压 + * + * @param string $name 资源名称 + * @param string $url 下载链接 + * @param string $filename 下载到下载目录的目标文件名称,例如 xz.tar.gz + * @param null|string $path 如果指定了此参数,则会移动该资源目录到目标目录 + * @throws FileSystemException + * @throws RuntimeException + * @throws DownloaderException + */ + public static function downloadUrl(string $name, string $url, string $filename, ?string $path = null): void + { + if (!file_exists(DOWNLOAD_PATH . "/{$filename}")) { + logger()->debug("downloading {$url}"); + self::curlDown(url: $url, path: DOWNLOAD_PATH . "/{$filename}"); + } else { + logger()->notice("{$filename} already exists"); + } + FileSystem::extractSource($name, DOWNLOAD_PATH . "/{$filename}"); + if ($path) { + $path = FileSystem::convertPath(SOURCE_PATH . "/{$path}"); + $src_path = FileSystem::convertPath(SOURCE_PATH . "/{$name}"); + switch (PHP_OS_FAMILY) { + case 'Windows': + f_passthru('move "' . $src_path . '" "' . $path . '"'); + break; + case 'Linux': + case 'Darwin': + f_passthru('mv "' . $src_path . '" "' . $path . '"'); + break; + } + } + } + + /** + * 拉取资源 + * + * @param string $name 资源名称 + * @param array $source 资源参数,包含 type、path、rev、url、filename、regex、license + * @throws DownloaderException + * @throws RuntimeException + * @throws FileSystemException + */ + public static function fetchSource(string $name, array $source): void + { + // 避免重复 fetch + if (is_dir(FileSystem::convertPath(SOURCE_PATH . "/{$name}")) || isset($source['path']) && is_dir(FileSystem::convertPath(SOURCE_PATH . "/{$source['path']}"))) { + logger()->notice("{$name} source already extracted"); + return; + } + try { + switch ($source['type']) { + case 'bitbuckettag': // 从 BitBucket 的 Tag 拉取 + [$url, $filename] = self::getLatestBitbucketTag($name, $source); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'ghtar': // 从 GitHub 的 TarBall 拉取 + [$url, $filename] = self::getLatestGithubTarball($name, $source); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'ghtagtar': // 根据 GitHub 的 Tag 拉取相应版本的 Tar + [$url, $filename] = self::getLatestGithubTarball($name, $source, 'tags'); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'ghrel': // 通过 GitHub Release 来拉取 + [$url, $filename] = self::getLatestGithubRelease($name, $source); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'filelist': // 通过网站提供的 filelist 使用正则提取后拉取 + [$url, $filename] = self::getFromFileList($name, $source); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'url': // 通过直链拉取 + $url = $source['url']; + $filename = $source['filename'] ?? basename($source['url']); + self::downloadUrl($name, $url, $filename, $source['path'] ?? null); + break; + case 'git': // 通过拉回 Git 仓库的形式拉取 + if ($source['path'] ?? null) { + $path = SOURCE_PATH . "/{$source['path']}"; + } else { + $path = SOURCE_PATH . "/{$name}"; + } + if (file_exists($path)) { + logger()->notice("{$name} source already exists"); + break; + } + logger()->debug("cloning {$name} source"); + $check = !defined('DEBUG_MODE') ? ' -q' : ''; + f_passthru( + 'git clone' . $check . + ' --config core.autocrlf=false ' . + "--branch \"{$source['rev']}\" " . (defined('GIT_SHALLOW_CLONE') ? '--depth 1 --single-branch' : '') . " --recursive \"{$source['url']}\" \"{$path}\"" + ); + break; + default: + throw new DownloaderException('unknown source type: ' . $source['type']); + } + } catch (RuntimeException $e) { + // 因为某些时候通过命令行下载的文件在失败后不会删除,这里检测到文件存在需要手动删一下 + if (isset($filename) && file_exists(DOWNLOAD_PATH . '/' . $filename)) { + logger()->warning('Deleting download file: ' . $filename); + unlink(DOWNLOAD_PATH . '/' . $filename); + } + throw $e; + } + } + + /** + * 获取 PHP x.y 的具体版本号,例如通过 8.1 来获取 8.1.10 + * + * @throws DownloaderException + */ + #[ArrayShape(['type' => 'string', 'path' => 'string', 'rev' => 'string', 'url' => 'string'])] + public static function getLatestPHPInfo(string $major_version): array + { + // 查找最新的小版本号 + $info = json_decode(self::curlExec(url: "https://www.php.net/releases/index.php?json&version={$major_version}"), true); + $version = $info['version']; + + // 从官网直接下载 + return [ + 'type' => 'url', + 'url' => "https://www.php.net/distributions/php-{$version}.tar.gz", + ]; + } + + /** + * 使用 curl 命令拉取元信息 + * + * @throws DownloaderException + */ + public static function curlExec(string $url, string $method = 'GET', array $headers = [], array $hooks = []): string + { + foreach ($hooks as $hook) { + $hook($method, $url, $headers); + } + + FileSystem::findCommandPath('curl'); + + $methodArg = match ($method) { + 'GET' => '', + 'HEAD' => '-I', + default => "-X \"{$method}\"", + }; + $headerArg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers)); + + $cmd = "curl -sfSL {$methodArg} {$headerArg} \"{$url}\""; + f_exec($cmd, $output, $ret); + if ($ret !== 0) { + throw new DownloaderException('failed http fetch'); + } + return implode("\n", $output); + } + + /** + * 使用 curl 命令下载文件 + * + * @throws DownloaderException + * @throws RuntimeException + */ + public static function curlDown(string $url, string $path, string $method = 'GET', array $headers = [], array $hooks = []): void + { + foreach ($hooks as $hook) { + $hook($method, $url, $headers); + } + + $methodArg = match ($method) { + 'GET' => '', + 'HEAD' => '-I', + default => "-X \"{$method}\"", + }; + $headerArg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers)); + $check = !defined('DEBUG_MODE') ? 's' : '#'; + $cmd = "curl -{$check}fSL -o \"{$path}\" {$methodArg} {$headerArg} \"{$url}\""; + f_passthru($cmd); + } +} diff --git a/src/SPC/store/FileSystem.php b/src/SPC/store/FileSystem.php new file mode 100644 index 00000000..c891cf82 --- /dev/null +++ b/src/SPC/store/FileSystem.php @@ -0,0 +1,379 @@ +debug('Reading file: ' . $filename); + $r = file_get_contents(self::convertPath($filename)); + if ($r === false) { + throw new FileSystemException('Reading file ' . $filename . ' failed'); + } + return $r; + } + + /** + * @throws FileSystemException + */ + public static function replaceFile(string $filename, int $replace_type = REPLACE_FILE_STR, mixed $callback_or_search = null, mixed $to_replace = null): bool|int + { + logger()->debug('Replacing file with type[' . $replace_type . ']: ' . $filename); + $file = self::readFile($filename); + switch ($replace_type) { + case REPLACE_FILE_STR: + default: + $file = str_replace($callback_or_search, $to_replace, $file); + break; + case REPLACE_FILE_PREG: + $file = preg_replace($callback_or_search, $to_replace, $file); + break; + case REPLACE_FILE_USER: + $file = $callback_or_search($file); + break; + } + return file_put_contents($filename, $file); + } + + /** + * 获取文件后缀 + * + * @param string $fn 文件名 + */ + public static function extname(string $fn): string + { + $parts = explode('.', basename($fn)); + if (count($parts) < 2) { + return ''; + } + return array_pop($parts); + } + + /** + * 寻找命令的真实路径,效果类似 which + * + * @param string $name 命令名称 + * @param array $paths 路径列表,如果为空则默认从 PATH 系统变量搜索 + */ + public static function findCommandPath(string $name, array $paths = []): ?string + { + if (!$paths) { + $paths = explode(PATH_SEPARATOR, getenv('PATH')); + } + if (PHP_OS_FAMILY === 'Windows') { + foreach ($paths as $path) { + foreach (['.exe', '.bat', '.cmd'] as $suffix) { + if (file_exists($path . DIRECTORY_SEPARATOR . $name . $suffix)) { + return $path . DIRECTORY_SEPARATOR . $name . $suffix; + } + } + } + return null; + } + foreach ($paths as $path) { + if (file_exists($path . DIRECTORY_SEPARATOR . $name)) { + return $path . DIRECTORY_SEPARATOR . $name; + } + } + return null; + } + + public static function copyDir(string $from, string $to): void + { + $iterator = new \RecursiveIteratorIterator(new RecursiveDirectoryIterator($from, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO), \RecursiveIteratorIterator::SELF_FIRST); + foreach ($iterator as $item) { + /** + * @var \SplFileInfo $item + */ + $target = $to . substr($item->getPathname(), strlen($from)); + if ($item->isDir()) { + logger()->info("mkdir {$target}"); + f_mkdir($target, recursive: true); + } else { + logger()->info("copying {$item} to {$target}"); + @f_mkdir(dirname($target), recursive: true); + copy($item->getPathname(), $target); + } + } + } + + /** + * 解压缩下载的资源包到 source 目录 + * + * @param string $name 资源名 + * @param string $filename 文件名 + * @throws FileSystemException + * @throws RuntimeException + */ + public static function extractSource(string $name, string $filename): void + { + logger()->info("extracting {$name} source"); + try { + if (PHP_OS_FAMILY !== 'Windows') { + if (f_mkdir(directory: SOURCE_PATH . "/{$name}", recursive: true) !== true) { + throw new FileSystemException('create ' . $name . 'source dir failed'); + } + switch (self::extname($filename)) { + case 'xz': + case 'txz': + f_passthru("tar -xf {$filename} -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + // f_passthru("cat {$filename} | xz -d | tar -x -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + case 'gz': + case 'tgz': + f_passthru("tar -xzf {$filename} -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + case 'bz2': + f_passthru("tar -xjf {$filename} -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + case 'zip': + f_passthru("unzip {$filename} -d " . SOURCE_PATH . "/{$name}"); + break; + // case 'zstd': + // case 'zst': + // passthru('cat ' . $filename . ' | zstd -d | tar -x -C ".SOURCE_PATH . "/' . $name . ' --strip-components 1', $ret); + // break; + case 'tar': + f_passthru("tar -xf {$filename} -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + default: + throw new FileSystemException('unknown archive format: ' . $filename); + } + } else { + // find 7z + $_7zExe = self::findCommandPath('7z', [ + 'C:\Program Files\7-Zip-Zstandard', + 'C:\Program Files (x86)\7-Zip-Zstandard', + 'C:\Program Files\7-Zip', + 'C:\Program Files (x86)\7-Zip', + ]); + if (!$_7zExe) { + throw new FileSystemException('windows needs 7z to unpack'); + } + f_mkdir(SOURCE_PATH . "/{$name}", recursive: true); + switch (self::extname($filename)) { + case 'zstd': + case 'zst': + if (!str_contains($_7zExe, 'Zstandard')) { + throw new FileSystemException("zstd is not supported: {$filename}"); + } + // no break + case 'xz': + case 'txz': + case 'gz': + case 'tgz': + case 'bz2': + f_passthru("\"{$_7zExe}\" x -so {$filename} | tar -f - -x -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + case 'tar': + f_passthru("tar -xf {$filename} -C " . SOURCE_PATH . "/{$name} --strip-components 1"); + break; + case 'zip': + f_passthru("\"{$_7zExe}\" x {$filename} -o" . SOURCE_PATH . "/{$name}"); + break; + default: + throw new FileSystemException("unknown archive format: {$filename}"); + } + } + } catch (RuntimeException $e) { + if (PHP_OS_FAMILY === 'Windows') { + f_passthru('rmdir /s /q ' . SOURCE_PATH . "/{$name}"); + } else { + f_passthru('rm -r ' . SOURCE_PATH . "/{$name}"); + } + throw new FileSystemException('Cannot extract source ' . $name, $e->getCode(), $e); + } + } + + /** + * 根据系统环境的不同,自动转换路径的分隔符 + * + * @param string $path 路径 + */ + public static function convertPath(string $path): string + { + if (str_starts_with($path, 'phar://')) { + return $path; + } + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + /** + * 递归或非递归扫描目录,可返回相对目录的文件列表或绝对目录的文件列表 + * + * @param string $dir 目录 + * @param bool $recursive 是否递归扫描子目录 + * @param bool|string $relative 是否返回相对目录,如果为true则返回相对目录,如果为false则返回绝对目录 + * @param bool $include_dir 非递归模式下,是否包含目录 + * @return array|false + * @since 2.5 + */ + public static function scanDirFiles(string $dir, bool $recursive = true, bool|string $relative = false, bool $include_dir = false): bool|array + { + $dir = self::convertPath($dir); + // 不是目录不扫,直接 false 处理 + if (!is_dir($dir)) { + logger()->warning('Scan dir failed, no such directory.'); + return false; + } + logger()->debug('scanning directory ' . $dir); + // 套上 zm_dir + $scan_list = scandir($dir); + if ($scan_list === false) { + logger()->warning('Scan dir failed, cannot scan directory: ' . $dir); + return false; + } + $list = []; + // 将 relative 置为相对目录的前缀 + if ($relative === true) { + $relative = $dir; + } + // 遍历目录 + foreach ($scan_list as $v) { + // Unix 系统排除这俩目录 + if ($v == '.' || $v == '..') { + continue; + } + $sub_file = self::convertPath($dir . '/' . $v); + if (is_dir($sub_file) && $recursive) { + # 如果是 目录 且 递推 , 则递推添加下级文件 + $list = array_merge($list, self::scanDirFiles($sub_file, $recursive, $relative)); + } elseif (is_file($sub_file) || is_dir($sub_file) && !$recursive && $include_dir) { + # 如果是 文件 或 (是 目录 且 不递推 且 包含目录) + if (is_string($relative) && mb_strpos($sub_file, $relative) === 0) { + $list[] = ltrim(mb_substr($sub_file, mb_strlen($relative)), '/\\'); + } elseif ($relative === false) { + $list[] = $sub_file; + } + } + } + return $list; + } + + /** + * @param null|mixed $rule + * @param mixed $return_path_value + * @throws FileSystemException + */ + public static function getClassesPsr4(string $dir, string $base_namespace, $rule = null, $return_path_value = false): array + { + $classes = []; + // 扫描目录,使用递归模式,相对路径模式,因为下面此路径要用作转换成namespace + $files = FileSystem::scanDirFiles($dir, true, true); + if ($files === false) { + throw new FileSystemException('Cannot scan dir files during get classes psr-4 from dir: ' . $dir); + } + foreach ($files as $v) { + $pathinfo = pathinfo($v); + if (($pathinfo['extension'] ?? '') == 'php') { + $path = rtrim($dir, '/') . '/' . rtrim($pathinfo['dirname'], './') . '/' . $pathinfo['basename']; + + // 过滤不包含类的文件 + $tokens = token_get_all(self::readFile($path)); + $found = false; + foreach ($tokens as $token) { + if (!is_array($token)) { + continue; + } + if ($token[0] === T_CLASS) { + $found = true; + break; + } + } + if (!$found) { + continue; + } + + if ($rule === null) { // 规则未设置回调时候,使用默认的识别过滤规则 + /*if (substr(file_get_contents($dir . '/' . $v), 6, 6) == '#plain') { + continue; + }*/ + if (file_exists($dir . '/' . $pathinfo['basename'] . '.ignore')) { + continue; + } + if (mb_substr($pathinfo['basename'], 0, 7) == 'global_' || mb_substr($pathinfo['basename'], 0, 7) == 'script_') { + continue; + } + } elseif (is_callable($rule) && !$rule($dir, $pathinfo)) { + continue; + } + $dirname = $pathinfo['dirname'] == '.' ? '' : (str_replace('/', '\\', $pathinfo['dirname']) . '\\'); + $class_name = $base_namespace . '\\' . $dirname . $pathinfo['filename']; + if (is_string($return_path_value)) { + $classes[$class_name] = $return_path_value . '/' . $v; + } else { + $classes[] = $class_name; + } + } + } + return $classes; + } + + /** + * 删除目录及目录下的所有文件(危险操作) + * + * @throws FileSystemException + */ + public static function removeDir(string $dir, bool $throw_on_fail = false): bool + { + $dir = FileSystem::convertPath($dir); + logger()->warning('Removing path recursively: "' . $dir . '"'); + switch (PHP_OS_FAMILY) { + case 'Windows': + case 'WINNT': + case 'Cygwin': + f_exec('rmdir /s /g "' . $dir . '"', $out, $ret); + break; + case 'Darwin': + case 'Linux': + f_exec('rm -rf ' . escapeshellarg($dir), $out, $ret); + break; + default: + throw new FileSystemException('Unsupported OS type: ' . PHP_OS_FAMILY); + } + if ($ret !== 0 && $throw_on_fail) { + throw new FileSystemException('Cannot remove dir "' . $dir . '"'); + } + return $ret === 0; + } +} diff --git a/src/SPC/util/ConfigValidator.php b/src/SPC/util/ConfigValidator.php new file mode 100644 index 00000000..3d129071 --- /dev/null +++ b/src/SPC/util/ConfigValidator.php @@ -0,0 +1,71 @@ + $src) { + isset($src['type']) || throw new ValidationException("source {$name} must have prop: [type]"); + is_string($src['type']) || throw new ValidationException("source {$name} type prop must be string"); + in_array($src['type'], ['filelist', 'git', 'ghtagtar', 'ghtar', 'ghrel', 'url']) || throw new ValidationException("source {$name} type [{$src['type']}] is invalid"); + switch ($src['type']) { + case 'filelist': + isset($src['url'], $src['regex']) || throw new ValidationException("source {$name} needs [url] and [regex] props"); + is_string($src['url']) && is_string($src['regex']) || throw new ValidationException("source {$name} [url] and [regex] must be string"); + break; + case 'git': + isset($src['url'], $src['rev']) || throw new ValidationException("source {$name} needs [url] and [rev] props"); + is_string($src['url']) && is_string($src['rev']) || throw new ValidationException("source {$name} [url] and [rev] must be string"); + is_string($src['path'] ?? '') || throw new ValidationException("source {$name} [path] must be string"); + break; + case 'ghtagtar': + case 'ghtar': + isset($src['repo']) || throw new ValidationException("source {$name} needs [repo] prop"); + is_string($src['repo']) || throw new ValidationException("source {$name} [repo] must be string"); + is_string($src['path'] ?? '') || throw new ValidationException("source {$name} [path] must be string"); + break; + case 'ghrel': + isset($src['repo'], $src['match']) || throw new ValidationException("source {$name} needs [repo] and [match] props"); + is_string($src['repo']) && is_string($src['match']) || throw new ValidationException("source {$name} [repo] and [match] must be string"); + break; + case 'url': + isset($src['url']) || throw new ValidationException("source {$name} needs [url] prop"); + is_string($src['url']) || throw new ValidationException("source {$name} [url] must be string"); + break; + } + } + } + + /** + * @param mixed $data + * @throws ValidationException + */ + public static function validateLibs($data, array $source_data = []): void + { + is_array($data) || throw new ValidationException('lib.json is broken'); + foreach ($data as $name => $lib) { + isset($lib['source']) || throw new ValidationException("lib {$name} does not assign any source"); + is_string($lib['source']) || throw new ValidationException("lib {$name} source must be string"); + empty($source_data) || isset($source_data[$lib['source']]) || throw new ValidationException("lib {$name} assigns an invalid source: {$lib['source']}"); + !isset($lib['lib-depends']) || !is_assoc_array($lib['lib-depends']) || throw new ValidationException("lib {$name} dependencies must be a list"); + !isset($lib['lib-suggests']) || !is_assoc_array($lib['lib-suggests']) || throw new ValidationException("lib {$name} suggested dependencies must be a list"); + } + } + + public static function validateExts($data, array $source_data = []): void + { + is_array($data) || throw new ValidationException('ext.json is broken'); + } +} diff --git a/src/globals/defines.php b/src/globals/defines.php new file mode 100644 index 00000000..67a8b645 --- /dev/null +++ b/src/globals/defines.php @@ -0,0 +1,43 @@ + 'x86_64', + 'arm64', 'aarch64' => 'aarch64', + default => throw new \SPC\exception\RuntimeException('Not support arch: ' . $arch), + // 'armv7' => 'arm', + }; +} + +function quote(string $str, string $quote = '"'): string +{ + return $quote . $str . $quote; +} + +function osfamily2dir(): string +{ + return match (PHP_OS_FAMILY) { + /* @phpstan-ignore-next-line */ + 'Windows', 'WINNT', 'Cygwin' => 'windows', + 'Darwin' => 'macos', + 'Linux' => 'linux', + default => throw new \SPC\exception\RuntimeException('Not support os: ' . PHP_OS_FAMILY), + }; +} + +/** + * @throws \SPC\exception\RuntimeException + */ +function f_passthru(string $cmd): ?bool +{ + $danger = false; + foreach (DANGER_CMD as $danger_cmd) { + if (str_starts_with($cmd, $danger_cmd . ' ')) { + $danger = true; + break; + } + } + if ($danger) { + logger()->notice('Running dangerous command: ' . $cmd); + } else { + logger()->debug('Running command with direct output: ' . $cmd); + } + $ret = passthru($cmd, $code); + if ($code !== 0) { + throw new \SPC\exception\RuntimeException('Command run failed with code[' . $code . ']: ' . $cmd, $code); + } + return $ret; +} + +function f_exec(string $command, &$output, &$result_code) +{ + logger()->debug('Running command (no output) : ' . $command); + return exec($command, $output, $result_code); +} + +function f_mkdir(string $directory, int $permissions = 0777, bool $recursive = false): bool +{ + if (file_exists($directory)) { + logger()->debug("Dir {$directory} already exists, ignored"); + return true; + } + logger()->debug('Making new directory ' . ($recursive ? 'recursive' : '') . ': ' . $directory); + return mkdir($directory, $permissions, $recursive); +}