modulesPath = base_path('modules'); $this->pluginsPath = base_path('plugins'); $this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php'); } /** * 설치된 모듈/플러그인의 오토로드 파일을 생성합니다. * * composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여 * 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다. * * 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다. */ public function updateComposerAutoload(): void { // 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화) // phpunit.xml에서 APP_ENV=testing으로 설정됨 if (app()->environment('testing')) { return; } $this->generateAutoloadFile(); // 현재 프로세스의 Composer ClassLoader 에도 갱신된 PSR-4 를 즉시 반영. // 파일 쓰기만으로는 다음 요청 부트스트랩 시점부터 적용되므로, 업데이트 실행 // 흐름(copyToActive → updateComposerAutoload → runUpgradeSteps) 내에서 // 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가 // 실패하지 않도록 런타임 재등록을 수행한다. $this->reregisterRuntimeAutoload(); } /** * 현재 프로세스의 Composer ClassLoader 에 갱신된 PSR-4 매핑을 재등록합니다. * * `generateAutoloadFile()` 은 autoload-extensions.php 를 디스크에 다시 쓰지만, * 이 파일은 CoreServiceProvider::register() / public/index.php 진입점에서만 * 로드되므로, 동일 프로세스 내부에서 PSR-4 네임스페이스가 추가·변경된 경우 * 다음 부트스트랩 이전까지 신규 매핑이 반영되지 않는다. * * 본 메서드는 `Composer\Autoload\ClassLoader::getRegisteredLoaders()` 로 현재 * 프로세스에 등록된 ClassLoader 를 조회하여 `addPsr4()` 를 다시 호출해 신규 * 매핑을 즉시 유효화한다. 기존 매핑에 경로가 추가되거나 새 네임스페이스가 * 등록되며, 동일 매핑은 중복 없이 merge 된다. */ protected function reregisterRuntimeAutoload(): void { if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) { return; } if (! file_exists($this->autoloadFilePath)) { return; } $loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders(); if (empty($loaders)) { return; } foreach ($loaders as $loader) { self::registerExtensionAutoload($loader); break; } } /** * 오토로드 파일을 생성합니다. * * bootstrap/cache/autoload-extensions.php 파일에 * 설치된 모듈/플러그인의 PSR-4 매핑과 classmap을 저장합니다. */ public function generateAutoloadFile(): void { // 모듈별 오토로드 수집 $moduleAutoloads = $this->collectModuleAutoloads(); // 플러그인별 오토로드 수집 $pluginAutoloads = $this->collectPluginAutoloads(); // PSR-4 병합 $psr4 = array_merge( $moduleAutoloads['psr4'], $pluginAutoloads['psr4'] ); // Classmap 병합 (module.php, plugin.php 등) $classmap = array_merge( $moduleAutoloads['classmap'], $pluginAutoloads['classmap'] ); // Files 병합 (헬퍼 함수 등) $files = array_merge( $moduleAutoloads['files'], $pluginAutoloads['files'] ); // Vendor autoloads 병합 (모듈/플러그인의 composer 의존성) $vendorAutoloads = array_merge( $moduleAutoloads['vendor_autoloads'], $pluginAutoloads['vendor_autoloads'] ); // 파일 내용 생성 $content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads); // 디렉토리 확인 $dir = dirname($this->autoloadFilePath); if (! File::isDirectory($dir)) { File::makeDirectory($dir, 0755, true); } // 파일 저장 File::put($this->autoloadFilePath, $content); Log::info('확장 오토로드 파일 생성 완료', [ 'path' => $this->autoloadFilePath, 'psr4_count' => count($psr4), 'classmap_count' => count($classmap), 'files_count' => count($files), 'vendor_autoloads_count' => count($vendorAutoloads), ]); } /** * 오토로드 파일 내용을 생성합니다. * * @param array $psr4 PSR-4 네임스페이스 매핑 * @param array $classmap 클래스맵 파일 목록 * @param array $files 헬퍼 파일 목록 * @return string PHP 파일 내용 */ protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string { $generatedAt = now()->toDateTimeString(); // JSON으로 변환 후 PHP 배열 문법으로 변환 (깔끔한 들여쓰기) $psr4Json = json_encode($psr4, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // JSON을 PHP 배열 문법으로 변환 $psr4Php = $this->jsonToPhpArray($psr4Json, 1); $classmapPhp = $this->jsonToPhpArray($classmapJson, 1); $filesPhp = $this->jsonToPhpArray($filesJson, 1); $vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1); return << {$psr4Php}, 'classmap' => {$classmapPhp}, 'files' => {$filesPhp}, 'vendor_autoloads' => {$vendorAutoloadsPhp}, ]; PHP; } /** * JSON 문자열을 PHP 배열 문법으로 변환합니다. * * @param string $json JSON 문자열 * @param int $baseIndent 기본 들여쓰기 레벨 * @return string PHP 배열 문자열 */ protected function jsonToPhpArray(string $json, int $baseIndent = 0): string { // JSON을 PHP 배열 문법으로 변환 $php = str_replace(['{', '}', ':'], ['[', ']', ' =>'], $json); // 들여쓰기 조정 $lines = explode("\n", $php); $result = []; $indent = str_repeat(' ', $baseIndent); foreach ($lines as $i => $line) { if ($i === 0) { // 첫 줄 (여는 괄호)는 그대로 $result[] = $line; } elseif ($i === count($lines) - 1) { // 마지막 줄 (닫는 괄호) $result[] = $indent.$line; } else { // 중간 줄 - JSON의 기본 4칸 들여쓰기에 base 들여쓰기 추가 $result[] = $indent.$line; } } return implode("\n", $result); } /** * Composer ClassLoader에 확장 오토로드를 등록합니다. * * public/index.php 및 artisan에서 호출됩니다. * * @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스 */ public static function registerExtensionAutoload($loader): void { $autoloadFile = base_path('bootstrap/cache/autoload-extensions.php'); if (! file_exists($autoloadFile)) { return; } $autoloads = require $autoloadFile; // PSR-4 네임스페이스 등록 if (! empty($autoloads['psr4'])) { foreach ($autoloads['psr4'] as $namespace => $paths) { // 경로가 배열인 경우와 문자열인 경우 모두 처리 $paths = (array) $paths; foreach ($paths as $path) { $absolutePath = base_path($path); if (is_dir($absolutePath)) { $loader->addPsr4($namespace, $absolutePath); } } } } // Classmap 파일 로드 (module.php, plugin.php) if (! empty($autoloads['classmap'])) { foreach ($autoloads['classmap'] as $file) { $absolutePath = base_path($file); if (file_exists($absolutePath)) { require_once $absolutePath; } } } // Files 로드 (헬퍼 함수 등) if (! empty($autoloads['files'])) { foreach ($autoloads['files'] as $file) { $absolutePath = base_path($file); if (file_exists($absolutePath)) { require_once $absolutePath; } } } // Vendor autoloads 로드 (모듈/플러그인의 composer 의존성) if (! empty($autoloads['vendor_autoloads'])) { foreach ($autoloads['vendor_autoloads'] as $vendorAutoload) { $absolutePath = base_path($vendorAutoload); if (file_exists($absolutePath)) { require_once $absolutePath; } } } } /** * 설치된 모듈의 오토로드 설정을 수집합니다. * * @return array ['psr4' => [...], 'classmap' => [...], 'files' => [...]] */ protected function collectModuleAutoloads(): array { $psr4 = []; $classmap = []; $files = []; $vendorAutoloads = []; if (! File::exists($this->modulesPath)) { return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } // 데이터베이스 테이블이 존재하지 않으면 빈 배열 반환 (마이그레이션 전) if (! Schema::hasTable('modules')) { return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } // 설치된 모듈의 identifier 목록 가져오기 $installedModules = $this->moduleRepository->getAll(); $installedIdentifiers = $installedModules->pluck('identifier')->toArray(); $moduleDirs = File::directories($this->modulesPath); foreach ($moduleDirs as $moduleDir) { $moduleName = basename($moduleDir); // _bundled, _pending 등 내부 디렉토리 건너뛰기 if (str_starts_with($moduleName, '_')) { continue; } $composerFile = $moduleDir.'/composer.json'; if (File::exists($composerFile)) { $moduleComposer = json_decode(File::get($composerFile), true); // 디렉토리명이 곧 identifier (예: sirsoft-sample) $identifier = $moduleName; // 설치된 모듈만 처리 if (! in_array($identifier, $installedIdentifiers)) { continue; } // PSR-4 오토로드 추가 if (isset($moduleComposer['autoload']['psr-4'])) { foreach ($moduleComposer['autoload']['psr-4'] as $namespace => $path) { // 경로가 배열인 경우 처리 if (is_array($path)) { $psr4[$namespace] = array_map( fn ($p) => 'modules/'.$moduleName.'/'.$p, $path ); } else { $psr4[$namespace] = 'modules/'.$moduleName.'/'.$path; } } } // module.php를 classmap에 추가 $moduleFile = $moduleDir.'/module.php'; if (File::exists($moduleFile)) { $classmap[] = 'modules/'.$moduleName.'/module.php'; } // Files 오토로드 추가 (헬퍼 함수 등) if (isset($moduleComposer['autoload']['files'])) { foreach ($moduleComposer['autoload']['files'] as $file) { $files[] = 'modules/'.$moduleName.'/'.$file; } } // Vendor autoload 추가 (모듈 자체 composer 의존성) $vendorAutoloadFile = $moduleDir.'/vendor/autoload.php'; if (File::exists($vendorAutoloadFile)) { $vendorAutoloads[] = 'modules/'.$moduleName.'/vendor/autoload.php'; } } } return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } /** * 설치된 플러그인의 오토로드 설정을 수집합니다. * * @return array ['psr4' => [...], 'classmap' => [...], 'files' => [...]] */ protected function collectPluginAutoloads(): array { $psr4 = []; $classmap = []; $files = []; $vendorAutoloads = []; if (! File::exists($this->pluginsPath)) { return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } // 데이터베이스 테이블이 존재하지 않으면 빈 배열 반환 (마이그레이션 전) if (! Schema::hasTable('plugins')) { return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } // 설치된 플러그인의 identifier 목록 가져오기 $installedPlugins = $this->pluginRepository->getAll(); $installedIdentifiers = $installedPlugins->pluck('identifier')->toArray(); $pluginDirs = File::directories($this->pluginsPath); foreach ($pluginDirs as $pluginDir) { $pluginName = basename($pluginDir); // _bundled, _pending 등 내부 디렉토리 건너뛰기 if (str_starts_with($pluginName, '_')) { continue; } $composerFile = $pluginDir.'/composer.json'; if (File::exists($composerFile)) { $pluginComposer = json_decode(File::get($composerFile), true); // 디렉토리명이 곧 identifier (예: sirsoft-payment) $identifier = $pluginName; // 설치된 플러그인만 처리 if (! in_array($identifier, $installedIdentifiers)) { continue; } // PSR-4 오토로드 추가 if (isset($pluginComposer['autoload']['psr-4'])) { foreach ($pluginComposer['autoload']['psr-4'] as $namespace => $path) { // 경로가 배열인 경우 처리 if (is_array($path)) { $psr4[$namespace] = array_map( fn ($p) => 'plugins/'.$pluginName.'/'.$p, $path ); } else { $psr4[$namespace] = 'plugins/'.$pluginName.'/'.$path; } } } // plugin.php를 classmap에 추가 $pluginFile = $pluginDir.'/plugin.php'; if (File::exists($pluginFile)) { $classmap[] = 'plugins/'.$pluginName.'/plugin.php'; } // Files 오토로드 추가 (헬퍼 함수 등) if (isset($pluginComposer['autoload']['files'])) { foreach ($pluginComposer['autoload']['files'] as $file) { $files[] = 'plugins/'.$pluginName.'/'.$file; } } // Vendor autoload 추가 (플러그인 자체 composer 의존성) $vendorAutoloadFile = $pluginDir.'/vendor/autoload.php'; if (File::exists($vendorAutoloadFile)) { $vendorAutoloads[] = 'plugins/'.$pluginName.'/vendor/autoload.php'; } } } return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } /** * 모듈 identifier를 네임스페이스로 변환합니다. * * 예: 'sirsoft-ecommerce' → 'Modules\Sirsoft\Ecommerce\' * * @param string $identifier 모듈 식별자 (vendor-module 형식) * @return string PSR-4 네임스페이스 */ public static function moduleIdentifierToNamespace(string $identifier): string { return self::identifierToNamespace($identifier, 'Modules'); } /** * 플러그인 identifier를 네임스페이스로 변환합니다. * * 예: 'sirsoft-payment' → 'Plugins\Sirsoft\Payment\' * * @param string $identifier 플러그인 식별자 (vendor-plugin 형식) * @return string PSR-4 네임스페이스 */ public static function pluginIdentifierToNamespace(string $identifier): string { return self::identifierToNamespace($identifier, 'Plugins'); } /** * 확장 identifier를 네임스페이스로 변환합니다. * * identifier 형식: 'vendor-name' (예: 'sirsoft-ecommerce', 'sirsoft-payment') * 변환 결과: '{prefix}\Vendor\Name\' (예: 'Modules\Sirsoft\Ecommerce\') * * @param string $identifier 확장 식별자 * @param string $prefix 네임스페이스 접두어 (Modules 또는 Plugins) * @return string PSR-4 네임스페이스 */ protected static function identifierToNamespace(string $identifier, string $prefix): string { // identifier를 '-'로 분리하고 각 부분을 PascalCase로 변환 // 언더스코어도 단어 구분자로 처리 (예: daum_postcode → DaumPostcode) $parts = array_map( fn ($part) => str_replace(' ', '', ucwords(str_replace('_', ' ', $part))), explode('-', $identifier) ); // 네임스페이스 조합: Prefix\Vendor\Name\ return $prefix.'\\'.implode('\\', $parts).'\\'; } /** * 디렉토리명(vendor-name)을 네임스페이스(Vendor\Name)로 변환합니다. * * 하이픈('-')은 네임스페이스 구분자('\')로, 언더스코어('_')는 PascalCase 단어 경계로 처리됩니다. * 예: 'sirsoft-daum_postcode' → 'Sirsoft\DaumPostcode' * * @param string $directoryName 디렉토리명 (예: sirsoft-ecommerce, sirsoft-daum_postcode) * @return string 네임스페이스 (예: Sirsoft\Ecommerce, Sirsoft\DaumPostcode) */ public static function directoryToNamespace(string $directoryName): string { $parts = explode('-', $directoryName); $namespace = array_map(function ($part) { return str_replace(' ', '', ucwords(str_replace('_', ' ', $part))); }, $parts); return implode('\\', $namespace); } /** * FQCN 으로부터 등록된 확장(모듈/플러그인) 식별자를 추론합니다. * * `directoryToNamespace()` 의 역변환. PSR-4 prefix `Modules\` / `Plugins\` 의 * Vendor\Name 두 세그먼트를 kebab-case 식별자로 환원합니다. * * 예시: * - 'Modules\Sirsoft\Ecommerce\Models\Order' → 'sirsoft-ecommerce' * - 'Plugins\Sirsoft\Payment\Services\PaymentService' → 'sirsoft-payment' * - 'Modules\Sirsoft\DaumPostcode\Models\Address' → 'sirsoft-daum_postcode' * - 'App\Models\User' → null (코어) * * 등록 여부는 검증하지 않습니다 — 호출 측이 lang 파일 존재 여부로 fallback 처리합니다. * * @param string $fqcn 클래스 FQCN * @return string|null 모듈/플러그인 identifier, 코어/미해석 시 null */ public static function resolveExtensionByFqcn(string $fqcn): ?string { static $cache = []; $key = ltrim($fqcn, '\\'); if (array_key_exists($key, $cache)) { return $cache[$key]; } $cache[$key] = self::doResolveExtensionByFqcn($key); return $cache[$key]; } /** * resolveExtensionByFqcn 의 캐시되지 않은 본 구현. * * @param string $fqcn ltrim 된 FQCN * @return string|null 식별자 또는 null */ protected static function doResolveExtensionByFqcn(string $fqcn): ?string { if ($fqcn === '') { return null; } if (str_starts_with($fqcn, 'Modules\\')) { return self::namespaceTailToIdentifier(substr($fqcn, strlen('Modules\\'))); } if (str_starts_with($fqcn, 'Plugins\\')) { return self::namespaceTailToIdentifier(substr($fqcn, strlen('Plugins\\'))); } return null; } /** * `Vendor\Name\...` 꼬리에서 `vendor-name` 식별자를 추출합니다. * * @param string $tail 접두 (Modules\ / Plugins\) 제거 후의 FQCN 꼬리 * @return string|null 식별자 또는 null */ protected static function namespaceTailToIdentifier(string $tail): ?string { $parts = explode('\\', $tail); if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') { return null; } return self::pascalToKebabSegment($parts[0]).'-'.self::pascalToKebabSegment($parts[1]); } /** * 단일 PascalCase 세그먼트를 snake_case (단어 경계 `_` 사용) 로 변환합니다. * * `directoryToNamespace()` 의 역연산: * - 'Ecommerce' → 'ecommerce' * - 'DaumPostcode' → 'daum_postcode' * * @param string $pascal PascalCase 단일 세그먼트 * @return string snake_case 단일 세그먼트 */ protected static function pascalToKebabSegment(string $pascal): string { return strtolower((string) preg_replace('/(?validate( 'identifier', $identifier, function ($msg) use (&$failed, &$message) { $failed = true; $message = $msg; } ); if ($failed) { throw new \InvalidArgumentException($message); } } /** * 확장(모듈/플러그인)의 composer.json에서 PSR-4 매핑을 읽어 동적으로 오토로드를 등록합니다. * * 설치 시점에는 autoload-extensions.php가 아직 갱신되지 않으므로, * 시더 실행 전에 해당 확장의 네임스페이스를 Composer ClassLoader에 등록해야 합니다. * * @param string $type 확장 타입 ('modules' 또는 'plugins') * @param string $dirName 확장 디렉토리명 (예: 'sirsoft-ecommerce') */ public static function registerExtensionAutoloadPaths(string $type, string $dirName): void { $composerFile = base_path("{$type}/{$dirName}/composer.json"); if (! file_exists($composerFile)) { return; } $composerJson = json_decode(file_get_contents($composerFile), true); if (empty($composerJson['autoload']['psr-4'])) { return; } $loader = require base_path('vendor/autoload.php'); foreach ($composerJson['autoload']['psr-4'] as $namespace => $path) { $paths = is_array($path) ? $path : [$path]; foreach ($paths as $p) { $absolutePath = base_path("{$type}/{$dirName}/{$p}"); if (is_dir($absolutePath)) { $loader->addPsr4($namespace, $absolutePath); } } } } /** * 확장의 composer.json에 외부 패키지 의존성이 있는지 확인합니다. * * php, ext-* 확장을 제외한 외부 패키지만 확인합니다. * * @param string $type 확장 타입 ('modules' 또는 'plugins') * @param string $dirName 확장 디렉토리명 (예: 'sirsoft-ecommerce') * @return bool 외부 패키지 의존성 존재 여부 */ public function hasComposerDependencies(string $type, string $dirName): bool { return ! empty($this->getComposerDependencies($type, $dirName)); } /** * 지정 경로의 composer.json에 외부 패키지 의존성이 있는지 확인합니다. * * php, ext-* 확장을 제외한 외부 패키지만 확인합니다. * * @param string $path composer.json이 있는 디렉토리 경로 * @return bool 외부 패키지 의존성 존재 여부 */ public function hasComposerDependenciesAt(string $path): bool { return ! empty($this->getComposerDependenciesAt($path)); } /** * 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다. * * php, ext-* 확장을 제외한 외부 패키지만 반환합니다. * * @param string $type 확장 타입 ('modules' 또는 'plugins') * @param string $dirName 확장 디렉토리명 (예: 'sirsoft-ecommerce') * @return array 패키지 의존성 목록 ['vendor/package' => 'version', ...] */ public function getComposerDependencies(string $type, string $dirName): array { $composerFile = base_path("{$type}/{$dirName}/composer.json"); if (! file_exists($composerFile)) { return []; } $composerJson = json_decode(file_get_contents($composerFile), true); $require = $composerJson['require'] ?? []; // php, ext-* 제외 return array_filter($require, function (string $package) { return $package !== 'php' && ! str_starts_with($package, 'ext-'); }, ARRAY_FILTER_USE_KEY); } /** * 지정 경로의 composer.json에서 외부 패키지 의존성 목록을 반환합니다. * * php, ext-* 확장을 제외한 외부 패키지만 반환합니다. * * @param string $path composer.json이 있는 디렉토리 경로 * @return array 패키지 의존성 목록 ['vendor/package' => 'version', ...] */ public function getComposerDependenciesAt(string $path): array { $composerFile = $path.DIRECTORY_SEPARATOR.'composer.json'; if (! file_exists($composerFile)) { return []; } $composerJson = json_decode(file_get_contents($composerFile), true); $require = $composerJson['require'] ?? []; // php, ext-* 제외 return array_filter($require, function (string $package) { return $package !== 'php' && ! str_starts_with($package, 'ext-'); }, ARRAY_FILTER_USE_KEY); } /** * 스테이징과 활성 디렉토리의 composer.json/composer.lock이 동일한지 확인합니다. * * 두 파일이 모두 동일하면 composer install을 스킵할 수 있습니다. * 활성 디렉토리 미존재, vendor/ 미존재 시 false를 반환합니다. * * @param string $stagingPath 스테이징(신규) 디렉토리 * @param string $activePath 활성(현재) 디렉토리 * @return bool 두 파일이 모두 동일하면 true */ public function isComposerUnchanged(string $stagingPath, string $activePath): bool { // 활성 디렉토리 또는 vendor/ 미존재 → 반드시 설치 필요 if (! is_dir($activePath) || ! is_dir($activePath.DIRECTORY_SEPARATOR.'vendor')) { return false; } $stagingJson = $stagingPath.DIRECTORY_SEPARATOR.'composer.json'; $activeJson = $activePath.DIRECTORY_SEPARATOR.'composer.json'; // composer.json이 한쪽이라도 없으면 변경된 것으로 간주 if (! file_exists($stagingJson) || ! file_exists($activeJson)) { return false; } // composer.json 비교 if (md5_file($stagingJson) !== md5_file($activeJson)) { Log::info('composer.json 변경 감지', [ 'staging' => $stagingPath, 'active' => $activePath, ]); return false; } // composer.lock 비교 $stagingLock = $stagingPath.DIRECTORY_SEPARATOR.'composer.lock'; $activeLock = $activePath.DIRECTORY_SEPARATOR.'composer.lock'; $stagingLockExists = file_exists($stagingLock); $activeLockExists = file_exists($activeLock); // 한쪽만 존재하면 변경된 것으로 간주 if ($stagingLockExists !== $activeLockExists) { Log::info('composer.lock 존재 여부 불일치', [ 'staging_exists' => $stagingLockExists, 'active_exists' => $activeLockExists, ]); return false; } // 둘 다 존재하면 내용 비교 if ($stagingLockExists && $activeLockExists) { if (md5_file($stagingLock) !== md5_file($activeLock)) { Log::info('composer.lock 변경 감지', [ 'staging' => $stagingPath, 'active' => $activePath, ]); return false; } } Log::info('composer 의존성 변경 없음 — 스킵 가능', [ 'staging' => $stagingPath, 'active' => $activePath, ]); return true; } /** * 확장의 composer install을 실행합니다. * * @param string $type 확장 타입 ('modules' 또는 'plugins') * @param string $dirName 확장 디렉토리명 (예: 'sirsoft-ecommerce') * @param bool $noDev dev 의존성 제외 여부 * @param Command|null $command Artisan 커맨드 인스턴스 (출력용) * @return bool 성공 여부 */ public function runComposerInstall(string $type, string $dirName, bool $noDev = true, ?Command $command = null): bool { $extensionPath = base_path("{$type}/{$dirName}"); return $this->runComposerInstallAt($extensionPath, $noDev, $command); } /** * 지정된 경로에서 composer install을 실행합니다. * * 확장 활성 디렉토리뿐 아니라, _pending 스테이징 경로 등 * 임의의 경로에서도 composer install을 실행할 수 있습니다. * * @param string $extensionPath composer.json이 있는 디렉토리 경로 * @param bool $noDev dev 의존성 제외 여부 * @param Command|null $command Artisan 커맨드 인스턴스 (출력용) * @return bool 성공 여부 */ public function runComposerInstallAt(string $extensionPath, bool $noDev = true, ?Command $command = null): bool { $composerFile = $extensionPath.'/composer.json'; if (! file_exists($composerFile)) { return false; } try { $composerBinary = $this->findComposerBinary(); } catch (\RuntimeException $e) { Log::error('Composer 바이너리를 찾을 수 없습니다', ['error' => $e->getMessage()]); $command?->error('❌ '.$e->getMessage()); return false; } // Composer 실행 명령어 구성 $phpBinary = config('process.php_binary', 'php'); if (str_contains($composerBinary, ' ')) { // 공백 포함 = 전체 실행 명령어 (예: "/usr/local/php84/bin/php /home/user/g7/composer.phar") $commandArgs = array_merge(explode(' ', $composerBinary), ['install', '--no-interaction', '--optimize-autoloader']); } elseif (str_ends_with($composerBinary, '.phar')) { $commandArgs = [$phpBinary, $composerBinary, 'install', '--no-interaction', '--optimize-autoloader']; } else { $commandArgs = [$composerBinary, 'install', '--no-interaction', '--optimize-autoloader']; } if ($noDev) { $commandArgs[] = '--no-dev'; } // Windows 환경에서는 cmd /c 사용 if (PHP_OS_FAMILY === 'Windows') { $commandArgs = array_merge(['cmd', '/c'], $commandArgs); } $process = new Process($commandArgs); $process->setWorkingDirectory($extensionPath); $process->setTimeout(300); // 5분 타임아웃 // 인스톨러(install-worker.php)와 동일한 환경변수 구성 // 웹 서버 환경에서는 COMPOSER_HOME, TEMP 등이 없거나 쓰기 불가능할 수 있음 $env = []; foreach (['PATH', 'SystemRoot', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE'] as $key) { $value = getenv($key); if ($value !== false) { $env[$key] = $value; } } // Composer 관련 환경변수 $composerHome = storage_path('composer'); if (! is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } $env['COMPOSER_HOME'] = $composerHome; $env['HOME'] = $composerHome; // root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한 // 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응) $env['COMPOSER_ALLOW_SUPERUSER'] = '1'; $env['COMPOSER_NO_INTERACTION'] = '1'; // Windows: TEMP 디렉토리가 없거나 쓰기 불가능하면 storage/temp 사용 if (PHP_OS_FAMILY === 'Windows') { if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) { $tempDir = storage_path('temp'); if (! is_dir($tempDir)) { @mkdir($tempDir, 0755, true); } $env['TEMP'] = $tempDir; $env['TMP'] = $tempDir; } } $process->setEnv($env); $process->run(function ($outputType, $buffer) use ($command) { if ($command) { $command->getOutput()->write($buffer); } }); if ($process->isSuccessful()) { Log::info('확장 Composer 의존성 설치 완료', [ 'path' => $extensionPath, ]); return true; } Log::warning('확장 Composer 의존성 설치 실패', [ 'path' => $extensionPath, 'exit_code' => $process->getExitCode(), 'error' => $process->getErrorOutput(), ]); return false; } /** * Composer 바이너리 경로를 감지합니다. * * 감지 순서: * 1. 환경변수 COMPOSER_BINARY * 2. PATH의 composer * 3. 루트 디렉토리의 composer.phar * * @return string Composer 바이너리 경로 * * @throws \RuntimeException Composer를 찾을 수 없는 경우 */ private function findComposerBinary(): string { // 1. config('process.composer_binary') 우선 확인 $configBinary = config('process.composer_binary'); if ($configBinary) { // 공백 포함 = 전체 실행 명령어 → file_exists 체크 불필요 if (str_contains($configBinary, ' ') || file_exists($configBinary)) { return $configBinary; } } // 2. PATH에서 composer 검색 $whichCommand = PHP_OS_FAMILY === 'Windows' ? ['where', 'composer'] : ['which', 'composer']; $process = new Process($whichCommand); $process->run(); if ($process->isSuccessful()) { $path = trim(explode("\n", trim($process->getOutput()))[0]); if (! empty($path)) { return $path; } } // 3. 루트 디렉토리의 composer.phar $pharPath = base_path('composer.phar'); if (file_exists($pharPath)) { return $pharPath; } throw new \RuntimeException(__('exceptions.extension.composer_binary_not_found')); } /** * 여러 확장이 동일 패키지를 사용하는 경우를 감지합니다. * * @return array 중복 패키지 정보 ['package/name' => ['modules/ext1', 'plugins/ext2'], ...] */ public function detectDuplicatePackages(): array { $packageUsage = []; // 모듈 패키지 수집 if (File::exists($this->modulesPath)) { foreach (File::directories($this->modulesPath) as $moduleDir) { $moduleName = basename($moduleDir); if (str_starts_with($moduleName, '_')) { continue; } $deps = $this->getComposerDependencies('modules', $moduleName); foreach (array_keys($deps) as $package) { $packageUsage[$package][] = "modules/{$moduleName}"; } } } // 플러그인 패키지 수집 if (File::exists($this->pluginsPath)) { foreach (File::directories($this->pluginsPath) as $pluginDir) { $pluginName = basename($pluginDir); if (str_starts_with($pluginName, '_')) { continue; } $deps = $this->getComposerDependencies('plugins', $pluginName); foreach (array_keys($deps) as $package) { $packageUsage[$package][] = "plugins/{$pluginName}"; } } } // 2개 이상 확장에서 사용하는 패키지만 반환 return array_filter($packageUsage, fn ($users) => count($users) > 1); } // ────────────────────────────────────────────────── // GitHub 다운로드 유틸리티 (GithubHelper로 위임) // // `allow_url_fopen=Off` 공유 호스팅 대응을 위해 실제 HTTP 호출은 // `GithubHelper`의 Http 파사드 기반 구현을 사용합니다. // ────────────────────────────────────────────────── /** * 사용 가능한 아카이브 추출 전략을 구성합니다. * * @return array 추출 전략 배열 */ public function buildExtractionStrategies(): array { $strategies = []; // 1단계: ZipArchive (PHP zip 확장) if (class_exists(\ZipArchive::class)) { $strategies[] = [ 'archive_type' => 'zipball', 'method' => 'extractWithZipArchive', 'label' => 'ZipArchive', ]; } // 2단계: unzip 명령어 (Linux만) if (PHP_OS_FAMILY !== 'Windows' && $this->isUnzipAvailable()) { $strategies[] = [ 'archive_type' => 'zipball', 'method' => 'extractWithUnzip', 'label' => 'unzip', ]; } return $strategies; } /** * ZipArchive를 사용하여 아카이브를 추출합니다. * * @param string $zipPath ZIP 파일 경로 * @param string $extractDir 추출 대상 디렉토리 * @return void * * @throws \RuntimeException 추출 실패 시 */ public function extractWithZipArchive(string $zipPath, string $extractDir): void { $zip = new \ZipArchive; if ($zip->open($zipPath) !== true) { throw new \RuntimeException(__('settings.core_update.zip_extract_failed')); } $zip->extractTo($extractDir); $zip->close(); } /** * unzip 명령어를 사용하여 아카이브를 추출합니다. * * @param string $zipPath ZIP 파일 경로 * @param string $extractDir 추출 대상 디렉토리 * @return void * * @throws \RuntimeException 추출 실패 시 */ public function extractWithUnzip(string $zipPath, string $extractDir): void { $escapedZip = escapeshellarg($zipPath); $escapedDir = escapeshellarg($extractDir); exec("unzip -o {$escapedZip} -d {$escapedDir} 2>&1", $output, $exitCode); if ($exitCode !== 0) { throw new \RuntimeException(__('settings.core_update.unzip_command_failed', [ 'code' => $exitCode, 'output' => implode("\n", array_slice($output, -5)), ])); } } /** * unzip 명령어 사용 가능 여부를 확인합니다. * * @return bool */ public function isUnzipAvailable(): bool { exec('which unzip 2>/dev/null', $output, $exitCode); return $exitCode === 0; } /** * GitHub에서 확장을 다운로드하고 추출합니다. * * 코어 업데이트의 downloadUpdate()와 동일한 폴백 체인을 사용합니다. * (zipball/ZipArchive → zipball/unzip) * * 모든 HTTP 호출은 `GithubHelper` (Http 파사드 기반)로 위임되어 * `allow_url_fopen=Off` 환경에서도 정상 동작합니다. * * @param string $owner GitHub 저장소 소유자 * @param string $repo GitHub 저장소 이름 * @param string $version 버전 태그 * @param string $destDir 추출된 파일을 저장할 디렉토리 * @param string $token GitHub Personal Access Token (확장별 토큰, 기본 빈 문자열) * @return string 추출된 소스 디렉토리 경로 * * @throws \RuntimeException 다운로드/추출 실패 시 */ public function downloadAndExtractFromGitHub(string $owner, string $repo, string $version, string $destDir, string $token = ''): string { $extractDir = $destDir.DIRECTORY_SEPARATOR.'extracted'; $strategies = $this->buildExtractionStrategies(); if (empty($strategies)) { throw new \RuntimeException(__('settings.core_update.no_extract_method_available')); } $lastError = null; foreach ($strategies as $strategy) { $archiveType = $strategy['archive_type']; $extractMethod = $strategy['method']; $label = $strategy['label']; $archiveUrl = GithubHelper::resolveArchiveUrl($owner, $repo, $version, $archiveType, $token); if (! $archiveUrl) { continue; } $extension = $archiveType === 'zipball' ? '.zip' : '.tar.gz'; $archivePath = $destDir.DIRECTORY_SEPARATOR.'download'.$extension; try { GithubHelper::downloadArchive($archiveUrl, $archivePath, $token); if (File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } File::ensureDirectoryExists($extractDir); $this->$extractMethod($archivePath, $extractDir); // GitHub 아카이브는 owner-repo-hash/ 형태로 압축해제됨 $extractedDirs = File::directories($extractDir); if (empty($extractedDirs)) { throw new \RuntimeException(__('settings.core_update.extract_empty')); } $sourcePath = $extractedDirs[0]; File::delete($archivePath); return $sourcePath; } catch (\Exception $e) { $lastError = $e; if (File::exists($archivePath)) { File::delete($archivePath); } if (File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } Log::warning("GitHub 다운로드 폴백: {$label} 실패", [ 'owner' => $owner, 'repo' => $repo, 'version' => $version, 'error' => $e->getMessage(), ]); } } throw new \RuntimeException( __('settings.core_update.all_extract_methods_failed'), 0, $lastError ); } /** * 외부 ZIP 파일을 추출하여 소스 디렉토리 경로를 반환합니다. * * GitHub zipball 처럼 owner-repo-hash/ 래퍼 디렉토리로 감싼 경우와 * ZIP 루트가 곧바로 확장 소스인 경우를 모두 지원합니다. * 추출 후 extractDir 내용이 단일 디렉토리뿐이면 그 디렉토리를 반환하고, * 그 외에는 extractDir 자체를 반환합니다. * * downloadAndExtractFromGitHub 와 동일한 폴백 체인(ZipArchive → unzip)을 * 사용하며, GitHub 호출은 수행하지 않습니다. * * @param string $zipPath 외부 ZIP 파일 경로 * @param string $destDir 추출 작업 디렉토리 (함수가 'extracted' 하위에 추출) * @return string 확장 소스 디렉토리 경로 (래퍼 감지 후) * * @throws \RuntimeException 추출 실패 또는 지원 추출 수단 부재 시 */ public function extractFromZip(string $zipPath, string $destDir): string { if (! File::exists($zipPath)) { throw new \RuntimeException(__('settings.core_update.zip_file_not_found', ['path' => $zipPath])); } $strategies = $this->buildExtractionStrategies(); if (empty($strategies)) { throw new \RuntimeException(__('settings.core_update.no_extract_method_available')); } $extractDir = $destDir.DIRECTORY_SEPARATOR.'extracted'; if (File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } File::ensureDirectoryExists($extractDir); $lastError = null; foreach ($strategies as $strategy) { $method = $strategy['method']; $label = $strategy['label']; try { $this->$method($zipPath, $extractDir); return $this->resolveExtractedRoot($extractDir); } catch (\Throwable $e) { $lastError = $e; // 다음 전략 시도 전 extractDir 초기화 if (File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } File::ensureDirectoryExists($extractDir); Log::warning("ZIP 추출 폴백: {$label} 실패", [ 'zip' => $zipPath, 'error' => $e->getMessage(), ]); } } throw new \RuntimeException( __('settings.core_update.all_extract_methods_failed'), 0, $lastError ); } /** * 추출 디렉토리에서 확장 소스 루트를 판별합니다. * * - 하위에 파일이 있고 디렉토리가 0개 또는 2개 이상 → extractDir 자체가 소스 * - 하위에 디렉토리 1개만 있고 파일 없음 → 그 디렉토리가 래퍼 → 하위 반환 * - 혼합(파일 + 단일 디렉토리)인 경우 → extractDir 자체 반환 (manifest 루트에 있다고 가정) * * @param string $extractDir 추출 대상 디렉토리 * @return string 확장 소스 디렉토리 경로 */ protected function resolveExtractedRoot(string $extractDir): string { $dirs = File::directories($extractDir); $files = File::files($extractDir); if (count($dirs) === 1 && count($files) === 0) { return $dirs[0]; } return $extractDir; } /** * 외부 ZIP 소스를 스테이징 전단계까지 준비합니다. * * 각 확장 Manager(Module/Plugin/Template)의 --zip 업데이트 경로에서 * 공용으로 사용하는 헬퍼입니다. ZIP 을 임시 디렉토리에 추출하고 manifest 를 * 검증(파일 존재, identifier 일치, version 존재)한 뒤 결과를 반환합니다. * * 호출자는 반환된 temp_dir 을 반드시 정리해야 합니다 (try-finally 로 감싸는 것을 권장). * * @param string $zipPath 외부 ZIP 파일 경로 * @param string $identifier 기대하는 확장 식별자 (manifest 와 일치해야 함) * @param string $manifestName manifest 파일명 ('module.json' | 'plugin.json' | 'template.json') * @return array{temp_dir: string, extracted_dir: string, to_version: string, manifest: array} * * @throws \RuntimeException ZIP 추출 실패 / manifest 누락 / identifier 불일치 / version 누락 시 */ public function prepareZipSource(string $zipPath, string $identifier, string $manifestName): array { $tempDir = storage_path('app/temp/ext_zip_'.uniqid()); File::ensureDirectoryExists($tempDir); try { $extractedDir = $this->extractFromZip($zipPath, $tempDir); $manifestPath = $extractedDir.DIRECTORY_SEPARATOR.$manifestName; if (! File::exists($manifestPath)) { throw new \RuntimeException(__('extensions.errors.zip_missing_manifest', [ 'file' => $manifestName, 'zip' => $zipPath, ])); } $manifest = json_decode(File::get($manifestPath), true); if (! is_array($manifest)) { throw new \RuntimeException(__('extensions.errors.zip_invalid_manifest', [ 'file' => $manifestName, ])); } $manifestId = $manifest['identifier'] ?? null; if ($manifestId !== $identifier) { throw new \RuntimeException(__('extensions.errors.zip_identifier_mismatch', [ 'expected' => $identifier, 'actual' => $manifestId ?? '(missing)', ])); } $version = $manifest['version'] ?? null; if (! is_string($version) || $version === '') { throw new \RuntimeException(__('extensions.errors.zip_missing_version', [ 'file' => $manifestName, ])); } return [ 'temp_dir' => $tempDir, 'extracted_dir' => $extractedDir, 'to_version' => $version, 'manifest' => $manifest, ]; } catch (\Throwable $e) { if (File::isDirectory($tempDir)) { File::deleteDirectory($tempDir); } throw $e; } } }