Files
Gnuboard7/app/Extension/ExtensionManager.php
T
HeuJung 03cbb99196 fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.

플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.

실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
2026-08-21 17:09:30 +09:00

1642 lines
63 KiB
PHP

<?php
namespace App\Extension;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\Helpers\GithubHelper;
use App\Rules\ValidExtensionIdentifier;
use App\Support\ConfigCacheHelper;
use Composer\Autoload\ClassLoader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process;
/**
* 확장(모듈/플러그인) 공통 관리자
*
* 모듈과 플러그인에서 공통으로 사용되는 기능을 제공합니다.
* 런타임 오토로드 방식을 사용하여 composer.json 수정 없이 확장 클래스를 로드합니다.
*/
class ExtensionManager
{
protected string $modulesPath;
protected string $pluginsPath;
/**
* 오토로드 파일 경로
*/
protected string $autoloadFilePath;
public function __construct(
protected ModuleRepositoryInterface $moduleRepository,
protected PluginRepositoryInterface $pluginRepository
) {
$this->modulesPath = base_path('modules');
$this->pluginsPath = base_path('plugins');
$this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php');
}
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();
// 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성.
// 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로
// 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다.
$this->regenerateHookCache();
// config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로
// (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅
// 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다.
// activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다.
ConfigCacheHelper::rebuild();
}
/**
* 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다.
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
*/
public function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
$moduleManager->loadModules();
$pluginManager = app(PluginManager::class);
$pluginManager->loadPlugins();
app(HookCacheManager::class)->generate($moduleManager, $pluginManager);
} catch (\Throwable $e) {
// 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망.
Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [
'error' => $e->getMessage(),
]);
}
}
/**
* 현재 프로세스의 Composer ClassLoader 에 갱신된 PSR-4 매핑을 재등록합니다.
*
* `generateAutoloadFile()` 은 autoload-extensions.php 를 디스크에 다시 쓰지만,
* 이 파일은 CoreServiceProvider::register() / public/index.php 진입점에서만
* 로드되므로, 동일 프로세스 내부에서 PSR-4 네임스페이스가 추가·변경된 경우
* 다음 부트스트랩 이전까지 신규 매핑이 반영되지 않는다.
*
* 본 메서드는 `Composer\Autoload\ClassLoader::getRegisteredLoaders()` 로 현재
* 프로세스에 등록된 ClassLoader 를 조회하여 `addPsr4()` 를 다시 호출해 신규
* 매핑을 즉시 유효화한다. 기존 매핑에 경로가 추가되거나 새 네임스페이스가
* 등록되며, 동일 매핑은 중복 없이 merge 된다.
*/
public function reregisterRuntimeAutoload(): void
{
if (! class_exists(ClassLoader::class, false)) {
return;
}
if (! file_exists($this->autoloadFilePath)) {
return;
}
$loaders = 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
{
// 테스트 환경에서는 실 캐시 파일을 재생성하지 않는다.
// testing DB 의 활성 확장 구성은 개발 DB 와 다르므로, 어떤 경로로든 실 캐시가
// 재생성되면 개발 환경 PSR-4 매핑이 testing 기준으로 교체되어 모든 모듈/플러그인
// 라우트가 500 이 된다. 생성 로직 자체를 검증하는 테스트는 경로를 치환해 사용한다.
if ($this->writesRealCacheFileDuringTests()) {
return;
}
// 모듈별 오토로드 수집
$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']
);
// 확장 소스 classmap 생성 (FQCN → 상대경로).
// 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
$srcClassmap = $this->buildSourceClassmap($psr4);
// 공집합 산출물로 정상 매핑을 덮어쓰지 않는다.
// 이 매핑은 "DB 설치 목록 × 디스크 활성 디렉토리" 교집합이라, DB 쪽만 일시적으로
// 비어도(테이블 부재 · 설치 실패로 행 미기록 · 다른 DB 접속) 교집합이 공집합이 된다.
// 디스크에 설치 형태의 확장 디렉토리가 남아 있는데 매핑이 0건이면 DB 쪽 조회를
// 신뢰할 수 없다는 뜻이므로, 기존 파일을 보존하고 경고만 남긴다.
// (정상적인 0건 — 신규 설치 직후 · 마지막 확장 삭제 — 은 디스크도 함께 비어 통과)
$extensionDirectories = empty($psr4) ? $this->findInstalledExtensionDirectories() : [];
if (empty($psr4) && ! empty($extensionDirectories)) {
Log::warning('확장 오토로드 매핑이 0건으로 산출되어 기존 캐시를 보존합니다', [
'path' => $this->autoloadFilePath,
'extension_directories' => $extensionDirectories,
'hint' => '확장 테이블(modules/plugins) 조회 결과가 비어 있습니다. DB 연결·마이그레이션 상태를 확인한 뒤 extension:update-autoload 를 다시 실행하세요.',
]);
return;
}
// 파일 내용 생성
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
// 디렉토리 확인
$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),
'src_classmap_count' => count($srcClassmap),
'files_count' => count($files),
'vendor_autoloads_count' => count($vendorAutoloads),
]);
}
/**
* 현재 호출이 테스트 환경에서 실 캐시 파일을 건드리는지 판정합니다.
*
* 경로가 치환된(임시 경로) 인스턴스는 생성 로직 검증용이므로 통과시킨다.
*
* @return bool 테스트 환경에서 실 캐시 파일을 쓰려는 경우 true
*/
protected function writesRealCacheFileDuringTests(): bool
{
return app()->environment('testing')
&& $this->autoloadFilePath === base_path('bootstrap/cache/autoload-extensions.php');
}
/**
* 디스크에서 설치 형태를 갖춘 확장 디렉토리를 찾습니다.
*
* `_bundled` / `_pending` 등 내부 디렉토리는 제외하고, composer.json 을 가진
* 활성 디렉토리만 센다 (설치된 확장이 남긴 흔적).
*
* @return array<int, string> 확장 식별자 목록 (예: `modules/sirsoft-board`)
*/
protected function findInstalledExtensionDirectories(): array
{
$found = [];
foreach (['modules' => $this->modulesPath, 'plugins' => $this->pluginsPath] as $type => $basePath) {
if (! File::isDirectory($basePath)) {
continue;
}
foreach (File::directories($basePath) as $dir) {
$name = basename($dir);
if (str_starts_with($name, '_')) {
continue;
}
if (File::exists($dir.'/composer.json')) {
$found[] = $type.'/'.$name;
}
}
}
return $found;
}
/**
* 오토로드 파일 내용을 생성합니다.
*
* @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 = [], array $srcClassmap = []): 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);
$srcClassmapJson = json_encode($srcClassmap, 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);
$srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1);
return <<<PHP
<?php
/**
* 확장(모듈/플러그인) 오토로드 설정
*
* 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.
* Generated at: {$generatedAt}
*
* @see \\App\\Extension\\ExtensionManager::generateAutoloadFile()
*/
return [
'psr4' => {$psr4Php},
'classmap' => {$classmapPhp},
'src_classmap' => {$srcClassmapPhp},
'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 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 등록 (FQCN → 절대경로).
// findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include).
// PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망).
if (! empty($autoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($autoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// 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];
}
/**
* PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다.
*
* 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를
* 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어
* `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache /
* 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다.
*
* 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의
* `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다
* (vendor 격리 불변조건 보존).
*
* @param array<string, string|array<int, string>> $psr4 네임스페이스 → base_path 상대경로(들)
* @return array<string, string> FQCN → base_path 상대경로 (.php 포함)
*/
protected function buildSourceClassmap(array $psr4): array
{
$classmap = [];
foreach ($psr4 as $namespace => $paths) {
foreach ((array) $paths as $relativePath) {
$absoluteDir = base_path($relativePath);
if (! is_dir($absoluteDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$fqcn = $this->extractFqcnFromFile($file->getPathname());
if ($fqcn === null) {
continue;
}
// PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다.
// (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지)
if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) {
continue;
}
// base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관)
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
$classmap[$fqcn] = $rel;
}
}
}
ksort($classmap);
return $classmap;
}
/**
* PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다.
*
* 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null.
*
* @param string $filePath PHP 파일 절대경로
* @return string|null FQCN 또는 null
*/
protected function extractFqcnFromFile(string $filePath): ?string
{
$contents = @file_get_contents($filePath);
if ($contents === false) {
return null;
}
$tokens = token_get_all($contents);
$namespace = '';
$count = count($tokens);
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if (! is_array($token)) {
continue;
}
// 네임스페이스 수집
if ($token[0] === T_NAMESPACE) {
$namespace = '';
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if ($t === ';' || $t === '{') {
break;
}
if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) {
$namespace .= $t[1];
} elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) {
$namespace .= $t[1];
}
}
continue;
}
// 최상위 선언(class/interface/trait/enum) 감지
if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true)
|| (defined('T_ENUM') && $token[0] === T_ENUM)) {
// ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && $t[0] === T_WHITESPACE) {
continue;
}
if (is_array($t) && $t[0] === T_STRING) {
$class = $t[1];
return $namespace !== '' ? $namespace.'\\'.$class : $class;
}
break;
}
}
}
return null;
}
/**
* 모듈 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('/(?<!^)([A-Z])/', '_$1', $pascal));
}
/**
* 확장 식별자의 형식을 검증합니다.
*
* ValidExtensionIdentifier Rule을 직접 호출하여 검증하고,
* 실패 시 InvalidArgumentException을 throw합니다.
*
* @param string $identifier 확장 식별자
*
* @throws \InvalidArgumentException 식별자 형식이 올바르지 않을 때
*/
public static function validateIdentifierFormat(string $identifier): void
{
$failed = false;
$message = '';
(new ValidExtensionIdentifier)->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.vendor.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 추출 대상 디렉토리
*
* @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 추출 대상 디렉토리
*
* @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 unzip 실행 파일이 PATH 에 존재하면 true
*/
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;
}
}
}