") is not empty. * * @param string $plugin * @return bool */ protected function pluginExists(string $plugin): bool { $plugin = trim($plugin); if ($plugin === '') { return false; } $cfg = config("plugin.$plugin"); if ($cfg === null) { return false; } if (is_array($cfg)) { return $cfg !== []; } if (is_string($cfg)) { return trim($cfg) !== ''; } return (bool)$cfg; } /** * Extract plugin name from a relative path like "plugin//...". * * @param string|null $path * @return string|null */ protected function extractPluginNameFromRelativePath(?string $path): ?string { $path = $this->normalizeOptionValue($path); if (!$path) { return null; } $path = $this->normalizeRelativePath($path); if (preg_match('#^plugin/([^/]+)/#i', $path, $m)) { $name = trim((string)($m[1] ?? '')); return $name !== '' ? $name : null; } return null; } /** * Validate plugin existence and output error message when missing. * * @param string|null $plugin * @param \Symfony\Component\Console\Output\OutputInterface $output * @return bool */ protected function assertPluginExists(?string $plugin, \Symfony\Component\Console\Output\OutputInterface $output): bool { $plugin = $this->normalizeOptionValue($plugin); if (!$plugin) { return true; } if ($this->pluginExists($plugin)) { return true; } $output->writeln($this->renderPluginNotExistsMessage($plugin)); return false; } /** * @param string $plugin * @return string */ protected function renderPluginNotExistsMessage(string $plugin): string { $plugin = trim($plugin); $line1 = Util::selectByLocale([ 'zh_CN' => "插件不存在: {plugin}", 'zh_TW' => "外掛不存在: {plugin}", 'en' => "Plugin does not exist: {plugin}", 'ja' => "プラグインが存在しません: {plugin}", 'ko' => "플러그인이 존재하지 않습니다: {plugin}", 'fr' => "Le plugin n'existe pas : {plugin}", 'de' => "Plugin existiert nicht: {plugin}", 'es' => "El plugin no existe: {plugin}", 'pt_BR' => "O plugin não existe: {plugin}", 'ru' => "Плагин не существует: {plugin}", 'vi' => "Plugin không tồn tại: {plugin}", 'tr' => "Eklenti mevcut değil: {plugin}", 'id' => "Plugin tidak ada: {plugin}", 'th' => "ไม่พบปลั๊กอิน: {plugin}", ]); $line2 = Util::selectByLocale([ 'zh_CN' => '请检查插件名是否输入正确,或确认插件已正确安装/启用。', 'zh_TW' => '請檢查外掛名稱是否輸入正確,或確認外掛已正確安裝/啟用。', 'en' => 'Please check the plugin name, or make sure the plugin is installed/enabled.', 'ja' => 'プラグイン名を確認するか、プラグインがインストール/有効化されていることを確認してください。', 'ko' => '플러그인 이름을 확인하거나, 플러그인이 설치/활성화되었는지 확인하세요.', 'fr' => "Vérifiez le nom du plugin ou assurez-vous qu'il est installé/activé.", 'de' => 'Bitte prüfen Sie den Plugin-Namen oder ob das Plugin installiert/aktiviert ist.', 'es' => 'Compruebe el nombre del plugin o asegúrese de que esté instalado/habilitado.', 'pt_BR' => 'Verifique o nome do plugin ou se o plugin está instalado/ativado.', 'ru' => 'Проверьте имя плагина или убедитесь, что плагин установлен/включён.', 'vi' => 'Hãy kiểm tra tên plugin hoặc đảm bảo plugin đã được cài đặt/bật.', 'tr' => 'Eklenti adını kontrol edin veya eklentinin kurulu/etkin olduğundan emin olun.', 'id' => 'Periksa nama plugin atau pastikan plugin sudah terpasang/diaktifkan.', 'th' => 'โปรดตรวจสอบชื่อปลั๊กอิน หรือยืนยันว่าปลั๊กอินถูกติดตั้ง/เปิดใช้งานแล้ว', ]); return strtr($line1, ['{plugin}' => $plugin]) . "\n" . $line2; } /** * Symfony short options on some environments may return value like "=foo" for "-p=foo". * Normalize to "foo". * * @param mixed $value * @return string|null */ protected function normalizeOptionValue(mixed $value): ?string { if ($value === null) { return null; } $value = trim((string)$value); $value = ltrim($value, '='); return $value === '' ? null : $value; } /** * Normalize a relative path to use "/" separators and no leading/trailing slashes. * * @param string $path * @return string */ protected function normalizeRelativePath(string $path): string { $path = trim($path); $path = str_replace('\\', '/', $path); $path = preg_replace('#^\\./+#', '', $path); $path = trim($path, '/'); return $path; } /** * @param string $path * @return bool */ protected function isAbsolutePath(string $path): bool { $path = trim($path); if ($path === '') { return false; } // Windows drive letter, UNC path, or root slash. if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path)) { return true; } if (str_starts_with($path, '\\\\') || str_starts_with($path, '//')) { return true; } return str_starts_with($path, '/') || str_starts_with($path, '\\'); } /** * Compare two relative paths on Windows-friendly rules. * * @param string $a * @param string $b * @return bool */ protected function pathsEqual(string $a, string $b): bool { $a = strtolower($this->normalizeRelativePath($a)); $b = strtolower($this->normalizeRelativePath($b)); return $a === $b; } /** * Convert an absolute path to a workspace-relative path for nicer CLI output. * * @param string $path * @return string */ protected function toRelativePath(string $path): string { $base = base_path(); $baseNorm = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $base), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); if (str_starts_with(strtolower($pathNorm), strtolower($baseNorm))) { $rel = substr($pathNorm, strlen($baseNorm)); } else { $rel = $pathNorm; } // Use forward slashes for nicer CLI output. return str_replace(DIRECTORY_SEPARATOR, '/', $rel); } /** * Get current locale for CLI messages. Delegates to Util::getLocale(). * * @return string */ protected function getLocale(): string { return Util::getLocale(); } /** * Ask a question with Ctrl+C safety. * * On Windows, Ctrl+C during a prompt can corrupt the console code page, * causing subsequent UTF-8 output to display as garbled text. * This method restores the code page after every prompt to prevent that. * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output * @param \Symfony\Component\Console\Question\Question $question * @return mixed */ protected function askOrAbort( \Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output, \Symfony\Component\Console\Question\Question $question ): mixed { $cpBefore = function_exists('sapi_windows_cp_get') ? sapi_windows_cp_get() : null; /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ $helper = $this->getHelper('question'); try { $value = $helper->ask($input, $output, $question); } catch (\Throwable $e) { $this->restoreConsoleCodePage($cpBefore); throw $e; } $this->restoreConsoleCodePage($cpBefore); if ($value === null || (is_string($value) && str_contains($value, "\x03"))) { exit(130); } return $value; } /** * Restore console code page on Windows after an interactive prompt. * Ctrl+C can corrupt the code page; this resets it to what it was before. */ private function restoreConsoleCodePage(?int $codepage): void { if ($codepage !== null && function_exists('sapi_windows_cp_set')) { sapi_windows_cp_set($codepage); } } /** * Resolve namespace/file path by --plugin/-p or --path/-P. * - --path/-P: must be a relative path (to project root). * - If both are provided, they must point to the same directory, otherwise it's an error. * * @param string $name Name like "admin/User" * @param string|null $plugin * @param string|null $path * @param \Symfony\Component\Console\Output\OutputInterface $output * @param callable(string):string $pluginDefaultPathResolver Returns relative dir like "plugin/admin/app/controller" * @param callable(string,array):string $msg Message resolver: fn($key,$replace)=>string * @return array{0:string,1:string,2:string}|null [class, namespace, file] */ protected function resolveTargetByPluginOrPath( string $name, ?string $plugin, ?string $path, \Symfony\Component\Console\Output\OutputInterface $output, callable $pluginDefaultPathResolver, callable $msg ): ?array { $pathNorm = $path ? $this->normalizeRelativePath($path) : null; if ($pathNorm !== null && $this->isAbsolutePath($pathNorm)) { $output->writeln($msg('invalid_path', ['{path}' => (string)$path])); return null; } // Validate plugin existence (from --plugin/-p or inferred from --path/-P). $pluginToCheck = $this->normalizeOptionValue($plugin) ?: $this->extractPluginNameFromRelativePath($pathNorm); if ($pluginToCheck && !$this->assertPluginExists($pluginToCheck, $output)) { return null; } $expected = null; if ($plugin) { $expected = $pluginDefaultPathResolver($plugin); } if ($plugin && $pathNorm) { $pluginPrefix = 'plugin/' . $plugin . '/'; if (!str_starts_with(strtolower($pathNorm), strtolower($pluginPrefix))) { $output->writeln(strtr( Util::selectByLocale(Messages::getPluginPathConflictMessage()), ['{plugin}' => $plugin, '{path}' => $pathNorm] )); return null; } } $targetRel = $pathNorm ?: $expected; if (!$targetRel) { return null; } $targetDir = base_path($targetRel); $namespaceRoot = trim(str_replace('/', '\\', $targetRel), '\\'); if (!($pos = strrpos($name, '/'))) { $class = ucfirst($name); $subPath = ''; } else { $subPath = substr($name, 0, $pos); $class = ucfirst(substr($name, $pos + 1)); } $subDir = $subPath ? str_replace('/', DIRECTORY_SEPARATOR, $subPath) . DIRECTORY_SEPARATOR : ''; $file = $targetDir . DIRECTORY_SEPARATOR . $subDir . $class . '.php'; $namespace = $namespaceRoot . ($subPath ? '\\' . str_replace('/', '\\', $subPath) : ''); return [$class, $namespace, $file]; } /** * Load a php config file (return array). * * @param string $file * @return array|null null when file exists but does not return an array or cannot be included */ protected function loadPhpConfigArray(string $file): ?array { if (!is_file($file)) { return []; } try { $data = include $file; } catch (\Throwable) { return null; } return is_array($data) ? $data : null; } /** * Get a simple PHP header, preserving the top docblock if present. * * @param string $file * @return string */ protected function getPhpHeaderWithDocblock(string $file): string { $default = "loadPhpConfigArray($file); if ($config === null) { return false; } $classFqn = ltrim(trim($classFqn), '\\'); if (in_array($classFqn, $config, true)) { return false; } $config[] = $classFqn; $this->ensureParentDir($file); $header = $this->getPhpHeaderWithDocblock($file); $body = $this->renderFlatClassListConfig($config); file_put_contents($file, $header . $body); return true; } /** * Add a class to middleware config under empty key ''. * Return value format: * return [ * '' => [ * Foo::class, * ], * ]; * * @param string $file * @param string $classFqn e.g. app\middleware\StaticFile * @return bool changed or not */ protected function addClassToMiddlewareConfig(string $file, string $classFqn): bool { $config = $this->loadPhpConfigArray($file); if ($config === null) { return false; } $classFqn = ltrim(trim($classFqn), '\\'); if (!array_key_exists('', $config)) { // Put '' first for readability. $config = ['' => []] + $config; } $list = $config['']; if (!is_array($list)) { $list = []; } if (in_array($classFqn, $list, true)) { return false; } $list[] = $classFqn; $config[''] = $list; $this->ensureParentDir($file); $header = $this->getPhpHeaderWithDocblock($file); $body = $this->renderMiddlewareConfig($config); file_put_contents($file, $header . $body); return true; } /** * @param array $classes * @return string */ protected function renderFlatClassListConfig(array $classes): string { $lines = []; $lines[] = "return ["; foreach ($classes as $c) { $c = ltrim((string)$c, '\\'); if ($c === '') { continue; } $lines[] = " {$c}::class,"; } $lines[] = "];\n"; return implode("\n", $lines); } /** * @param array $config * @return string */ protected function renderMiddlewareConfig(array $config): string { $lines = []; $lines[] = "return ["; foreach ($config as $key => $value) { $keyExport = var_export((string)$key, true); $lines[] = " {$keyExport} => ["; $list = is_array($value) ? $value : []; foreach ($list as $c) { $c = ltrim((string)$c, '\\'); if ($c === '') { continue; } $lines[] = " {$c}::class,"; } $lines[] = " ],"; } $lines[] = "];\n"; return implode("\n", $lines); } }