Files
Gnuboard7/app/Services/LanguagePackService.php
HeuJung e7fa1b5535 feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
2026-08-24 14:51:37 +09:00

1973 lines
80 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Enums\LanguagePackStatus;
use App\Exceptions\LanguagePackOperationException;
use App\Exceptions\LanguagePackSlotConflictException;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\ZipInstallHelper;
use App\Extension\HookManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\LanguagePack;
use App\Services\LanguagePack\LanguagePackBaseLocales;
use App\Services\LanguagePack\LanguagePackManifestValidator;
use App\Services\LanguagePack\LanguagePackPhpArrayValidator;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Support\OutboundUrlValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* 언어팩 설치/활성화/비활성화/제거 서비스.
*
* 컨트롤러는 본 Service 에 위임하며, 입력 검증은 FormRequest 에서 사전 처리됩니다.
* Service 내부에서는 도메인 검증(manifest 구조, 의존성, 슬롯 충돌)만 수행합니다.
*/
class LanguagePackService
{
use ClearsTemplateCaches;
/**
* @param LanguagePackRepositoryInterface $repository 언어팩 Repository
* @param LanguagePackManifestValidator $validator manifest 검증기
* @param LanguagePackRegistry $registry 런타임 레지스트리
* @param CacheInterface $cache 캐시 인터페이스
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository,
private readonly LanguagePackManifestValidator $validator,
private readonly LanguagePackRegistry $registry,
private readonly CacheInterface $cache,
) {}
/**
* 페이지네이션된 언어팩 목록을 반환합니다.
*
* 모듈/플러그인 관리 화면의 풀 패리티를 위해, DB 에 등록된 언어팩과
* `lang-packs/_bundled/` 의 미설치 번들 가상 레코드를 합쳐 반환합니다.
*
* @param array<string, mixed> $filters 필터 조건 (scope/locale/status/search/vendor/page)
* @param int $perPage 페이지당 건수
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 병합 페이지네이션 결과
*/
public function list(array $filters = [], int $perPage = 20): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
$statusFilter = $filters['status'] ?? null;
$dbCollection = $this->repository->getFilteredCollection($filters);
$occupiedSlots = $this->collectSlotKeys($dbCollection);
// 가상 보호 행 (코어/번들 확장의 lang/{ko,en}/) — DB 슬롯에 없는 것만
$builtInVirtual = $this->getVirtualBuiltInPacks($filters)
->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)]))
->each(fn (LanguagePack $pack) => $occupiedSlots[$this->slotKey($pack)] = true);
// 미설치 가상 행 (lang-packs/_bundled/) — DB 슬롯 + built-in 슬롯에 없는 것만
$uninstalledVirtual = $this->shouldIncludeUninstalledBundled($statusFilter)
? $this->getUninstalledBundledPacks($filters)
->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)]))
: collect();
// DB + built-in + uninstalled 가상 행을 동일한 안정 키로 정렬해야
// 설치/활성화로 행 종류가 바뀌어도 페이지 위치가 흔들리지 않는다.
$merged = $dbCollection
->concat($builtInVirtual)
->concat($uninstalledVirtual)
->sortBy([
['scope', 'asc'],
['target_identifier', 'asc'],
['locale', 'asc'],
])
->values();
$page = max(1, (int) ($filters['page'] ?? 1));
$items = $this->annotateFilesMissing($merged->forPage($page, $perPage)->values());
return new LengthAwarePaginator(
$items,
$merged->count(),
$perPage,
$page,
['path' => Paginator::resolveCurrentPath()]
);
}
/**
* 한 확장(또는 코어) 에 귀속된 모든 로케일 언어팩을 머지하여 반환합니다.
*
* 모듈/플러그인/템플릿 detail API 의 `language_packs` 필드 데이터 소스로 사용되며,
* `list()` 와 동일한 머지 전략(DB > built_in 가상 > 미설치 번들 가상) 을 단일
* 슬롯 그룹(scope + target_identifier) 으로 한정해 적용합니다.
*
* @param LanguagePackScope $scope 대상 스코프 (module/plugin/template/core)
* @param string|null $targetIdentifier 대상 확장 식별자 (코어는 null)
* @return Collection<int, LanguagePack> 로케일별 정렬 된 머지 컬렉션
*/
public function getPacksForExtension(LanguagePackScope $scope, ?string $targetIdentifier): Collection
{
$filters = [
'scope' => $scope->value,
'target_identifier' => $targetIdentifier,
];
$dbCollection = $this->repository->getFilteredCollection($filters);
$occupiedSlots = $this->collectSlotKeys($dbCollection);
$builtInVirtual = $this->getVirtualBuiltInPacks($filters)
->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)]))
->each(fn (LanguagePack $pack) => $occupiedSlots[$this->slotKey($pack)] = true);
$uninstalledVirtual = $this->getUninstalledBundledPacks($filters)
->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)]));
return $this->annotateFilesMissing(
$dbCollection
->concat($builtInVirtual)
->concat($uninstalledVirtual)
->sortBy('locale')
->values()
);
}
/**
* 컬렉션에서 슬롯 키 (scope|target|locale) 맵을 생성합니다.
*
* @param Collection<int, LanguagePack> $collection 컬렉션
* @return array<string, true>
*/
private function collectSlotKeys(Collection $collection): array
{
return $collection
->mapWithKeys(fn (LanguagePack $pack) => [$this->slotKey($pack) => true])
->all();
}
/**
* 슬롯 키를 생성합니다.
*
* @param LanguagePack $pack 팩
* @return string 슬롯 키 (scope|target_identifier|locale)
*/
private function slotKey(LanguagePack $pack): string
{
return $pack->scope.'|'.($pack->target_identifier ?? '').'|'.$pack->locale;
}
/**
* 각 팩에 드리프트 파생 플래그 `files_missing` 와 복구 가능 여부 `bundled_source_available` 를 부여합니다.
*
* `files_missing` 은 DB 에 active 로 기록됐으나 설치본 디렉토리가 실재하지 않는 행을 표면화하기 위한 것으로,
* 이 상태의 팩은 런타임에 오류 없이 base locale 로 조용히 폴백한다(이슈 #496 의 드리프트 증상).
*
* `bundled_source_available` 은 그 팩을 `lang-packs/_bundled/` 소스로 재설치해 복구할 수 있는지를 나타낸다.
* 설치 경로(source_type)가 아니라 **번들 소스의 실재 여부**로 판정하므로, github/url/zip 으로 설치된 팩이라도
* 동일 식별자의 번들 소스가 있으면 복구 대상이 된다 — 판정을 source_type 에 걸면 그 팩들은 드리프트가
* 보이기만 하고 복구 수단이 없는 상태로 남는다.
*
* @param Collection<int, LanguagePack> $packs 대상 컬렉션
* @return Collection<int, LanguagePack> 동일 컬렉션(플래그 부여됨)
*/
private function annotateFilesMissing(Collection $packs): Collection
{
foreach ($packs as $pack) {
$pack->setAttribute('files_missing', $this->isInstalledButFilesMissing($pack));
$pack->setAttribute('bundled_source_available', $this->hasBundledSource($pack));
}
return $packs;
}
/**
* 팩과 동일한 식별자의 번들 소스(`lang-packs/_bundled/{identifier}`)가 실재하는지 확인합니다.
*
* @param LanguagePack $pack 판정 대상 팩
* @return bool 번들 소스가 있으면 true
*/
private function hasBundledSource(LanguagePack $pack): bool
{
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
if ($identifier === '') {
return false;
}
return File::isDirectory(base_path('lang-packs/_bundled/'.$identifier));
}
/**
* 설치본 파일이 부재(드리프트)해 번들 소스로 복구 가능한 설치 행을 반환합니다.
*
* `getUninstalledBundledPacks()` 는 슬롯이 점유된 팩을 후보에서 제외하므로 드리프트 행을 잡지 못합니다.
* 프로비저닝이 "수동 복구" 경로까지 겸하려면 이 집합이 함께 대상이 되어야 합니다.
*
* @param array<string, mixed> $filters 필터 (scope/locale/target_identifier/search/vendor)
* @return Collection<int, LanguagePack> 드리프트 설치 행 컬렉션
*/
public function getDriftedInstalledPacks(array $filters = []): Collection
{
return $this->annotateFilesMissing(
$this->repository
->getFilteredCollection($filters)
->filter(fn (LanguagePack $pack) => $this->isInstalledButFilesMissing($pack))
->filter(fn (LanguagePack $pack) => $this->hasBundledSource($pack))
->values()
);
}
/**
* 팩이 "설치(active) 로 기록됐으나 설치본 파일이 부재" 한 드리프트 상태인지 판정합니다.
*
* 가상 행(built_in / uninstalled)은 실제 설치본이 아니므로 대상에서 제외합니다.
*
* @param LanguagePack $pack 판정 대상 팩
* @return bool 드리프트면 true
*/
private function isInstalledButFilesMissing(LanguagePack $pack): bool
{
// 가상 행(exists=false: built_in/uninstalled)은 설치본 개념이 없으므로 제외
if (! $pack->exists) {
return false;
}
if ($pack->status !== LanguagePackStatus::Active->value) {
return false;
}
try {
return ! File::isDirectory($pack->resolveDirectory());
} catch (Throwable $e) {
return false;
}
}
/**
* 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack
* 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2).
*
* 스캔 대상 — **런타임이 실제로 로드하는 경로**와 같아야 한다. 어긋나면 관리자 화면이
* "내장 번역" 으로 보여주는 것과 화면에 나오는 문구의 출처가 달라진다.
* - 코어: lang/{ko,en}/
* - 번들 모듈: modules/_bundled/{id}/src/lang/{ko,en}/ (TranslationServiceProvider 기준)
* - 번들 플러그인: plugins/_bundled/{id}/lang/{ko,en}/
* - 번들 템플릿: templates/_bundled/{id}/lang/{ko,en}.json
*
* 발견된 각 슬롯에 대해 Repository::buildVirtualBuiltInPack() 으로 가상 행 합성.
* 모든 가상 행은 status=active, is_protected=true, source_type=built_in.
*
* @param array<string, mixed> $filters 필터 (scope/locale/target_identifier/search/vendor)
* @return Collection<int, LanguagePack> 가상 보호 LanguagePack 컬렉션
*/
public function getVirtualBuiltInPacks(array $filters = []): Collection
{
$packs = collect();
foreach (LanguagePackBaseLocales::all() as $locale) {
// 코어
if (File::isDirectory(base_path('lang/'.$locale))) {
$pack = $this->repository->buildVirtualBuiltInPack(
scope: LanguagePackScope::Core->value,
targetIdentifier: null,
locale: $locale,
vendor: 'g7',
version: (string) config('app.version', '0.0.0'),
langPathRelative: 'lang/'.$locale,
);
if ($this->matchesBuiltInFilters($pack, $filters)) {
$packs->push($pack);
}
}
// 번들 모듈 (런타임 등록 경로 = src/lang)
$packs = $packs->concat($this->scanBundledExtensionLang(
bundledRoot: 'modules/_bundled',
scope: LanguagePackScope::Module->value,
langSubpath: 'src/lang/'.$locale,
locale: $locale,
filters: $filters,
));
// 번들 플러그인
$packs = $packs->concat($this->scanBundledExtensionLang(
bundledRoot: 'plugins/_bundled',
scope: LanguagePackScope::Plugin->value,
langSubpath: 'lang/'.$locale,
locale: $locale,
filters: $filters,
));
// 번들 템플릿 (lang/{locale}.json 또는 lang/partial/{locale}/)
$packs = $packs->concat($this->scanBundledTemplateLang($locale, $filters));
}
return $packs->values();
}
/**
* 번들 확장 디렉토리(modules/_bundled, plugins/_bundled) 의 lang/{locale}/ 자원을 스캔합니다.
*
* @param string $bundledRoot base_path 기준 상대 경로 (예: 'modules/_bundled')
* @param string $scope 스코프 enum 값
* @param string $langSubpath 확장 디렉토리 하위의 lang 경로 (예: 'resources/lang/ko')
* @param string $locale 로케일
* @param array<string, mixed> $filters 필터
* @return Collection<int, LanguagePack>
*/
private function scanBundledExtensionLang(
string $bundledRoot,
string $scope,
string $langSubpath,
string $locale,
array $filters,
): Collection {
$absRoot = base_path($bundledRoot);
if (! File::isDirectory($absRoot)) {
return collect();
}
$packs = collect();
foreach (File::directories($absRoot) as $extDir) {
$extId = basename($extDir);
$langDir = $extDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $langSubpath);
if (! File::isDirectory($langDir)) {
continue;
}
$manifestPath = $extDir.DIRECTORY_SEPARATOR.$this->guessManifestFilename($scope);
$manifest = is_file($manifestPath) ? json_decode((string) @file_get_contents($manifestPath), true) : null;
$vendor = is_array($manifest) ? (string) ($manifest['vendor'] ?? explode('-', $extId)[0] ?? 'g7') : 'g7';
$version = is_array($manifest) ? (string) ($manifest['version'] ?? '0.0.0') : '0.0.0';
$pack = $this->repository->buildVirtualBuiltInPack(
scope: $scope,
targetIdentifier: $extId,
locale: $locale,
vendor: $vendor,
version: $version,
langPathRelative: $bundledRoot.'/'.$extId.'/'.$langSubpath,
);
if ($this->matchesBuiltInFilters($pack, $filters)) {
$packs->push($pack);
}
}
return $packs;
}
/**
* 번들 템플릿의 lang/{locale}.json 또는 lang/partial/{locale}/ 자원을 스캔합니다.
*
* @param string $locale 로케일
* @param array<string, mixed> $filters 필터
* @return Collection<int, LanguagePack>
*/
private function scanBundledTemplateLang(string $locale, array $filters): Collection
{
$absRoot = base_path('templates/_bundled');
if (! File::isDirectory($absRoot)) {
return collect();
}
$packs = collect();
foreach (File::directories($absRoot) as $tplDir) {
$tplId = basename($tplDir);
$hasJson = is_file($tplDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$locale.'.json');
$hasPartial = File::isDirectory($tplDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.'partial'.DIRECTORY_SEPARATOR.$locale);
if (! $hasJson && ! $hasPartial) {
continue;
}
$manifestPath = $tplDir.DIRECTORY_SEPARATOR.'template.json';
$manifest = is_file($manifestPath) ? json_decode((string) @file_get_contents($manifestPath), true) : null;
$vendor = is_array($manifest) ? (string) ($manifest['vendor'] ?? explode('-', $tplId)[0] ?? 'g7') : 'g7';
$version = is_array($manifest) ? (string) ($manifest['version'] ?? '0.0.0') : '0.0.0';
$pack = $this->repository->buildVirtualBuiltInPack(
scope: LanguagePackScope::Template->value,
targetIdentifier: $tplId,
locale: $locale,
vendor: $vendor,
version: $version,
langPathRelative: 'templates/_bundled/'.$tplId.'/lang',
);
if ($this->matchesBuiltInFilters($pack, $filters)) {
$packs->push($pack);
}
}
return $packs;
}
/**
* 스코프별 manifest 파일명을 추측합니다.
*
* @param string $scope 스코프
* @return string manifest 파일명
*/
private function guessManifestFilename(string $scope): string
{
return match ($scope) {
'module' => 'module.json',
'plugin' => 'plugin.json',
'template' => 'template.json',
default => 'manifest.json',
};
}
/**
* 가상 보호 행이 list 필터에 맞는지 검사합니다.
*
* @param LanguagePack $pack 가상 행
* @param array<string, mixed> $filters 필터
*/
private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool
{
if (! empty($filters['scope']) && $pack->scope !== $filters['scope']) {
return false;
}
if (! empty($filters['locale']) && $pack->locale !== $filters['locale']) {
return false;
}
if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) {
// 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (정정 사항).
// 코어 보호 팩은 환경설정 > 언어팩 관리(필터 없음) 화면에서만 노출됨.
if ($pack->target_identifier !== $filters['target_identifier']) {
return false;
}
}
if (! empty($filters['vendor']) && $pack->vendor !== $filters['vendor']) {
return false;
}
if (! empty($filters['exclude_protected']) && $pack->is_protected) {
return false;
}
if (! empty($filters['status']) && $filters['status'] !== LanguagePackStatus::Active->value) {
return false;
}
if (! empty($filters['search'])) {
$needle = mb_strtolower((string) $filters['search']);
$haystack = mb_strtolower($pack->identifier.' '.$pack->vendor.' '.$pack->locale.' '.$pack->locale_native_name);
if (! str_contains($haystack, $needle)) {
return false;
}
}
return true;
}
/**
* `lang-packs/_bundled/{identifier}` 디렉토리를 스캔하여, DB 에 동일 슬롯
* (scope/target_identifier/locale) 레코드가 없는 미설치 번들을 가상 LanguagePack
* 인스턴스로 반환합니다. 모듈/플러그인의 `getUninstalledModules()` 와 동일 패턴.
*
* 가상 인스턴스의 특성:
* - `id` 는 null (DB 미존재 → exists=false)
* - `status` = `LanguagePackStatus::Uninstalled` 가상값 (DB 컬럼에는 없음)
* - `source_type` = `bundled` (`installFromBundled` 가 받는 식별자와 동일)
* - 추가 가상 속성 `bundled_identifier` 로 행 액션이 사용할 식별자 노출
*
* 필터(filters)는 Repository 와 동일 의미로 처리하여 일관성을 유지합니다.
*
* @param array<string, mixed> $filters 필터 (scope/locale/target_identifier/search/vendor)
* @return Collection<int, LanguagePack> 미설치 번들 가상 LanguagePack 컬렉션
*/
public function getUninstalledBundledPacks(array $filters = []): Collection
{
$bundledRoot = base_path('lang-packs/_bundled');
if (! File::isDirectory($bundledRoot)) {
return collect();
}
$installedSlots = $this->repository
->getFilteredCollection([])
->mapWithKeys(fn (LanguagePack $pack) => [
$pack->scope.'|'.($pack->target_identifier ?? '').'|'.$pack->locale => true,
])
->all();
$packs = collect();
foreach (File::directories($bundledRoot) as $dir) {
$manifestPath = $dir.DIRECTORY_SEPARATOR.'language-pack.json';
if (! is_file($manifestPath)) {
continue;
}
$raw = @file_get_contents($manifestPath);
$manifest = $raw ? json_decode($raw, true) : null;
if (! is_array($manifest) || empty($manifest['identifier'])) {
continue;
}
$scope = $manifest['scope'] ?? null;
$locale = $manifest['locale'] ?? null;
if (! $scope || ! $locale) {
continue;
}
$targetIdentifier = $manifest['target_identifier'] ?? null;
$slotKey = $scope.'|'.($targetIdentifier ?? '').'|'.$locale;
if (isset($installedSlots[$slotKey])) {
continue;
}
if (! $this->matchesBundledFilters($manifest, $filters)) {
continue;
}
$virtual = $this->repository->buildVirtualFromManifest($manifest, basename($dir));
$virtual->setAttribute('install_blocked_reason', $this->resolveInstallBlockedReason($manifest));
$packs->push($virtual);
}
return $packs->values();
}
/**
* `status` 필터가 미설치 번들 포함을 허용하는지 판단합니다.
*
* @param string|null $statusFilter status 필터 값 (null/공백/uninstalled 만 허용)
* @return bool 포함 시 true
*/
private function shouldIncludeUninstalledBundled(?string $statusFilter): bool
{
if ($statusFilter === null || $statusFilter === '') {
return true;
}
return $statusFilter === LanguagePackStatus::Uninstalled->value;
}
/**
* 번들 manifest 가 필터 조건을 충족하는지 검사합니다 (Repository 와 동일 의미).
*
* @param array<string, mixed> $manifest 번들 manifest
* @param array<string, mixed> $filters 필터 조건
* @return bool 통과 시 true
*/
private function matchesBundledFilters(array $manifest, array $filters): bool
{
if (! empty($filters['scope']) && ($manifest['scope'] ?? null) !== $filters['scope']) {
return false;
}
if (! empty($filters['locale']) && ($manifest['locale'] ?? null) !== $filters['locale']) {
return false;
}
if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) {
if (($manifest['target_identifier'] ?? null) !== $filters['target_identifier']) {
return false;
}
}
if (! empty($filters['vendor']) && ($manifest['vendor'] ?? null) !== $filters['vendor']) {
return false;
}
if (! empty($filters['search'])) {
$needle = mb_strtolower((string) $filters['search']);
$haystack = mb_strtolower(implode(' ', array_filter([
(string) ($manifest['identifier'] ?? ''),
(string) ($manifest['vendor'] ?? ''),
(string) ($manifest['locale_name'] ?? ''),
(string) ($manifest['locale_native_name'] ?? ''),
])));
if (! str_contains($haystack, $needle)) {
return false;
}
}
// exclude_protected 는 DB 행 단계에서 LanguagePack::is_protected 컬럼으로 처리.
// 번들 manifest 는 보호 행을 표현하지 않으므로 (Repository::buildVirtualFromManifest 가 컨텍스트로 결정)
// 여기서는 항상 통과시킨다.
return true;
}
/**
* ID 로 언어팩을 조회합니다.
*
* @param int $id 언어팩 ID
* @return LanguagePack|null 언어팩 또는 null
*/
public function find(int $id): ?LanguagePack
{
return $this->repository->findById($id);
}
/**
* 식별자로 언어팩을 조회합니다.
*
* Artisan 커맨드(language-pack:update / language-pack:uninstall) 의 단일 진입점으로 사용됩니다.
*
* @param string $identifier 언어팩 식별자
* @return LanguagePack|null 언어팩 또는 null
*/
public function findByIdentifier(string $identifier): ?LanguagePack
{
return $this->repository->findByIdentifier($identifier);
}
/**
* 언어 셀렉터에 즉시 노출 가능한 활성 로케일 목록을 반환합니다.
*
* config('app.supported_locales') 의 허용 집합과 활성 코어
* 언어팩의 합집합을 반환하여 코어 기본 로케일이 누락되지 않도록
* 합니다. 언어팩 설치/활성화 직후 사용자 UI 갱신용으로 사용됩니다.
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getActiveLocales(): array
{
$configLocales = (array) config('app.supported_locales', ['ko', 'en']);
$activeLocales = $this->repository->getActiveCoreLocales();
return array_values(array_unique(array_merge($configLocales, $activeLocales)));
}
/**
* 정수 ID 또는 번들 식별자(문자열) 로 언어팩을 조회합니다.
*
* 정수면 `find()` 로 DB 레코드를, 식별자면 `lang-packs/_bundled/{id}` manifest 로부터
* `LanguagePackRepository::buildVirtualFromManifest()` 가상 모델을 반환합니다 (모듈 관리의 미설치 가상 행과 동일 패턴).
* 입력 형태별 dispatch 만 담당하며 검증/모델 합성은 모두 위임합니다.
*
* @param int|string $id ID(정수) 또는 번들 식별자
* @return LanguagePack|null 언어팩 또는 null
*/
public function findOrBundled(int|string $id): ?LanguagePack
{
if (is_numeric($id)) {
return $this->find((int) $id);
}
// 1) lang-packs/_bundled/{id}/ 의 ja 등 별도 패키지 가상 행
$manifestPath = base_path('lang-packs/_bundled/'.$id.'/language-pack.json');
if (File::isFile($manifestPath)) {
$manifest = json_decode(File::get($manifestPath), true);
if (is_array($manifest) && ($manifest['identifier'] ?? null) === $id) {
return $this->repository->buildVirtualFromManifest($manifest, (string) $id);
}
}
// 2) 코어/번들 확장의 lang/{ko,en}/ 가상 보호 행
foreach ($this->getVirtualBuiltInPacks() as $pack) {
if ($pack->identifier === $id) {
return $pack;
}
}
return null;
}
/**
* 업로드된 ZIP 파일에서 언어팩을 설치합니다.
*
* @param UploadedFile $file 업로드된 ZIP 파일
* @param bool $autoActivate 설치 후 자동 활성화 여부
* @param int|null $installedBy 설치자 사용자 ID
* @return LanguagePack 설치된 언어팩
*
* @throws RuntimeException 설치 실패 시
*/
public function installFromFile(UploadedFile $file, bool $autoActivate = false, ?int $installedBy = null): LanguagePack
{
$this->assertInstallDirectoriesWritable();
$tempId = (string) Str::uuid();
$extractPath = base_path('lang-packs/_pending/'.$tempId);
try {
$zipPath = $file->getRealPath();
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
}
/**
* GitHub URL 에서 언어팩을 설치합니다.
*
* @param string $githubUrl GitHub repository URL
* @param bool $autoActivate 설치 후 자동 활성화 여부
* @param int|null $installedBy 설치자 사용자 ID
* @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용)
* @return LanguagePack 설치된 언어팩
*
* @throws RuntimeException 설치 실패 시
*/
public function installFromGithub(string $githubUrl, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
{
$this->assertInstallDirectoriesWritable();
$tempId = (string) Str::uuid();
$tempPath = storage_path('app/temp/'.$tempId);
$extractPath = base_path('lang-packs/_pending/'.$tempId);
try {
File::ensureDirectoryExists($tempPath);
['owner' => $owner, 'repo' => $repo] = GithubHelper::parseUrl($githubUrl);
$zipPath = GithubHelper::downloadZip($owner, $repo, $tempPath);
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
} finally {
// 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
// catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
File::deleteDirectory($tempPath);
}
}
/**
* 임의 URL 에서 언어팩을 설치합니다.
*
* @param string $url ZIP 다운로드 URL
* @param string|null $checksum SHA-256 체크섬 (옵션)
* @param bool $autoActivate 설치 후 자동 활성화 여부
* @param int|null $installedBy 설치자 사용자 ID
* @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용)
* @return LanguagePack 설치된 언어팩
*
* @throws RuntimeException 설치 실패 시
*/
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
{
// 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다.
if (! OutboundUrlValidator::isPublicHttpUrl($url)) {
throw new LanguagePackOperationException('language_packs.errors.download_url_not_public');
}
$this->assertInstallDirectoriesWritable();
$tempId = (string) Str::uuid();
$tempPath = storage_path('app/temp/'.$tempId);
$extractPath = base_path('lang-packs/_pending/'.$tempId);
try {
File::ensureDirectoryExists($tempPath);
$zipPath = $tempPath.DIRECTORY_SEPARATOR.'pack.zip';
$response = Http::timeout(120)->get($url);
if (! $response->successful()) {
// 응답 상태를 사유로 싣는다 — 비우면 치환 자리가 남아 관리자 화면에
// 리터럴 ':error' 가 그대로 노출된다.
throw new LanguagePackOperationException('language_packs.errors.download_failed', [
'url' => $url,
'error' => 'HTTP '.$response->status(),
]);
}
File::put($zipPath, $response->body());
if ($checksum) {
$actual = hash_file('sha256', $zipPath);
if (! hash_equals($checksum, $actual)) {
throw new LanguagePackOperationException('language_packs.errors.checksum_mismatch');
}
}
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
} finally {
// 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
// catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
File::deleteDirectory($tempPath);
}
}
/**
* `lang-packs/_bundled/{identifier}` 의 번들 소스에서 언어팩을 설치(또는 재설치)합니다.
*
* 모듈/플러그인/템플릿의 `_bundled` 설치 패턴과 동일하게, 코어/공식 언어팩을
* 외부 다운로드 없이 로컬 번들 디렉토리에서 가져와 활성 디렉토리로 복사합니다.
* 시더만으로는 손상된 활성 디렉토리를 복구할 수 없는 운영상의 갭을 메웁니다.
*
* @param string $identifier 번들 디렉토리 식별자 (lang-packs/_bundled/{identifier})
* @param bool $autoActivate 설치 후 자동 활성화 여부
* @param int|null $installedBy 설치자 사용자 ID
* @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용)
* @return LanguagePack 설치/갱신된 언어팩
*
* @throws RuntimeException 번들 디렉토리/manifest 부재 또는 검증 실패 시
*/
public function installFromBundled(string $identifier, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
{
$this->assertInstallDirectoriesWritable();
$bundledPath = base_path('lang-packs/_bundled/'.$identifier);
if (! File::isDirectory($bundledPath)) {
throw new LanguagePackOperationException('language_packs.errors.bundled_not_found', ['identifier' => $identifier]);
}
$tempId = (string) Str::uuid();
$extractPath = base_path('lang-packs/_pending/'.$tempId);
try {
File::ensureDirectoryExists(dirname($extractPath));
File::copyDirectory($bundledPath, $extractPath);
return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
}
/**
* _pending 디렉토리에서 manifest 검증, 디렉토리 이동, DB 등록까지 수행합니다.
*
* @param string $extractPath 추출된 디렉토리 경로
* @param string $sourceType 설치 소스 유형 (zip/github/url)
* @param string $sourceUrl 설치 소스 URL/파일명
* @param bool $autoActivate 설치 후 자동 활성화 여부
* @param int|null $installedBy 설치자 사용자 ID
* @return LanguagePack 설치/갱신된 언어팩
*
* @throws RuntimeException 검증/의존성/충돌 실패 시
*/
private function finalizeInstall(
string $extractPath,
string $sourceType,
string $sourceUrl,
bool $autoActivate,
?int $installedBy,
bool $force = false
): LanguagePack {
$manifestPath = ZipInstallHelper::findManifest($extractPath, 'language-pack.json');
if (! $manifestPath) {
throw new LanguagePackOperationException('language_packs.errors.manifest_not_found');
}
$manifestRaw = File::get($manifestPath);
$manifest = json_decode($manifestRaw, true);
if (! is_array($manifest)) {
throw new LanguagePackOperationException('language_packs.errors.manifest_invalid_json');
}
$packageRoot = dirname($manifestPath);
$this->validator->validate($manifest, $packageRoot);
$this->assertSecurityRules($packageRoot, $manifest);
$this->assertDependencies($manifest);
$this->assertTargetExtensionExists($manifest);
$identifier = $manifest['identifier'];
$existing = $this->repository->findByIdentifier($identifier);
if ($existing && ! $force) {
// force=true 는 모듈/플러그인/템플릿 update --force 와 동일 의미 — 다운그레이드 우회 허용.
$this->assertNotDowngrade($existing, $manifest);
}
$finalPath = base_path('lang-packs/'.$identifier);
if (File::isDirectory($finalPath)) {
File::deleteDirectory($finalPath);
}
File::ensureDirectoryExists(dirname($finalPath));
File::moveDirectory($packageRoot, $finalPath);
if ($extractPath !== $packageRoot) {
File::deleteDirectory($extractPath);
}
// 재설치(update path) 시 자기 자신을 슬롯 충돌로 오인하지 않도록 $existing->id 를 제외.
// 이전엔 재설치 시 자기가 active 였더라도 $shouldActivate 가 false 가 되어
// status 가 active → installed 로 강등 → 의존하는 확장 언어팩이 'core_locale_missing'
// 으로 차단되는 회귀가 발생.
$shouldActivate = $autoActivate
&& ! $this->repository->findActiveForSlot(
$manifest['scope'],
$manifest['target_identifier'] ?? null,
$manifest['locale'],
$existing?->id
);
$pack = DB::transaction(function () use ($existing, $manifest, $sourceType, $sourceUrl, $installedBy) {
$data = $this->buildPackData($manifest, $sourceType, $sourceUrl, $installedBy);
// 설치 트랜잭션은 항상 installed 로만 기록한다. 활성화는 아래에서 activate() 를
// 경유해야 의존성 검사·대상 확장 존재 확인·슬롯 충돌 검사가 반드시 수행되고,
// activated 훅도 한 곳에서만 발화한다.
//
// 이 규칙의 부수 효과로, 이미 active 인 팩을 auto_activate 없이 덮어쓰면
// active → installed 로 강등된다. 겉보기엔 버그지만 의도된 안전장치다 —
// 활성 팩만 require 경로에 배선되므로, 방금 외부에서 반입된 내용이
// 활성화 권한자의 동의 없이 그 자리를 이어받는 일이 없어야 한다.
// (설치 권한만 가진 사용자가 활성 팩을 덮어쓰는 방식으로 활성화 권한 게이트를
// 우회하는 경로를 닫는다. 정상 운영자는 auto_activate 로 활성 상태를 유지하며,
// 그 플래그는 RequiresActivationPermission 이 지킨다.)
$data['status'] = LanguagePackStatus::Installed->value;
return $existing
? $this->repository->update($existing, $data)
: $this->repository->create($data);
});
// 캐시 전 계층 무효화 (registry + translator + template-lang)
$this->refreshCache();
HookManager::doAction(
$existing ? 'core.language_packs.updated' : 'core.language_packs.installed',
$pack
);
if ($shouldActivate) {
// activate() 내부에서 상태 기록·캐시 무효화·activated 훅 발화를 모두 담당한다.
$pack = $this->activate($pack, false);
}
return $pack;
}
/**
* 언어팩을 활성화합니다 (슬롯 스위칭).
*
* 동일 슬롯(scope + target_identifier + locale)에 이미 다른 active 팩이 있고
* `$force=false` 인 경우 `LanguagePackSlotConflictException` 을 던져 사용자에게
* 교체 확인을 요청한다. `$force=true` 인 경우 기존 팩을 inactive 로 강등하고
* 대상 팩을 active 로 승격한다.
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $force 기존 활성 팩이 있어도 강제로 교체
* @return LanguagePack 활성화된 언어팩
*
* @throws LanguagePackSlotConflictException 슬롯에 다른 활성 팩이 있고 force=false 인 경우
*/
public function activate(LanguagePack $pack, bool $force = false): LanguagePack
{
$this->assertNotInProgress($pack);
if ($pack->isActive()) {
return $pack;
}
// 요구사항 #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만)
$this->assertDependencies($pack->manifest ?? []);
$this->assertTargetExtensionExists($pack->manifest ?? []);
// 슬롯 충돌 사전 감지 — force=false 면 사용자 확인 모달을 위해 예외
$current = $this->repository->findActiveForSlot(
$pack->scope,
$pack->target_identifier,
$pack->locale
);
if ($current && $current->id !== $pack->id && ! $force) {
throw new LanguagePackSlotConflictException($current, $pack);
}
DB::transaction(function () use ($pack, $current) {
if ($current && $current->id !== $pack->id) {
$this->repository->update($current, [
'status' => LanguagePackStatus::Inactive->value,
]);
HookManager::doAction('core.language_packs.deactivated', $current);
}
$this->repository->update($pack, [
'status' => LanguagePackStatus::Active->value,
'activated_at' => now(),
]);
});
$this->refreshCache();
$fresh = $this->repository->findById($pack->id) ?? $pack;
HookManager::doAction('core.language_packs.activated', $fresh);
return $fresh;
}
/**
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* 각 ID 에 대해 activate() 를 호출하며, 실패한 항목은 reason 과 함께 응답에 포함합니다.
* 의존성 검사는 activate() 내부에서 자동 수행됩니다 (§4).
*
* @param array<int, int> $ids 언어팩 ID 배열
* @return array{succeeded: array<int, int>, failed: array<int, array<string, string>>}
*/
public function bulkActivate(array $ids): array
{
$succeeded = [];
$failed = [];
foreach ($ids as $id) {
$pack = $this->repository->findById((int) $id);
if (! $pack) {
$failed[] = ['id' => $id, 'reason' => 'not_found'];
continue;
}
try {
$this->activate($pack);
$succeeded[] = $pack->id;
} catch (Throwable $e) {
$failed[] = ['id' => $id, 'reason' => $e->getMessage()];
}
}
return ['succeeded' => $succeeded, 'failed' => $failed];
}
/**
* 언어팩을 비활성화합니다.
*
* 슬롯에 다른 후보가 존재하면 가장 최근 inactive/installed 팩이 자동 active 로 승격됩니다.
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $cascadeFromHost 호스트 확장 비활성화로 인한 cascade 호출 여부 (true 면 보호 행/protected 검증 우회)
* @return LanguagePack 비활성화된 언어팩
*/
public function deactivate(LanguagePack $pack, bool $cascadeFromHost = false): LanguagePack
{
$this->assertNotInProgress($pack);
if (! $pack->isActive()) {
return $pack;
}
// 요구사항 #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회
if ($pack->isProtected() && ! $cascadeFromHost) {
throw new LanguagePackOperationException('language_packs.errors.protected_pack');
}
DB::transaction(function () use ($pack) {
$this->repository->update($pack, [
'status' => LanguagePackStatus::Inactive->value,
]);
$this->promoteSlotSuccessor($pack);
});
$this->refreshCache();
HookManager::doAction('core.language_packs.deactivated', $pack);
return $this->repository->findById($pack->id) ?? $pack;
}
/**
* 언어팩을 제거합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거
*/
public function uninstall(LanguagePack $pack, bool $cascade = false): void
{
if ($pack->isProtected()) {
throw new LanguagePackOperationException('language_packs.errors.protected_pack');
}
$this->assertNotInProgress($pack);
DB::transaction(function () use ($pack, $cascade) {
if ($pack->isActive()) {
$this->promoteSlotSuccessor($pack);
}
if ($pack->source_type !== 'bundled_with_extension') {
$directory = $pack->resolveDirectory();
if (File::isDirectory($directory)) {
File::deleteDirectory($directory);
}
}
$this->repository->delete($pack);
if ($cascade && $pack->scope === LanguagePackScope::Core->value) {
$relatedPacks = $this->repository
->getPacksForLocale($pack->locale)
->filter(fn (LanguagePack $p) => $p->scope !== LanguagePackScope::Core->value);
foreach ($relatedPacks as $related) {
$this->repository->update($related, [
'status' => LanguagePackStatus::Inactive->value,
]);
}
}
});
$this->refreshCache();
HookManager::doAction('core.language_packs.uninstalled', $pack);
}
/**
* 슬롯 비활성화 후 다음 후보를 active 로 승격합니다.
*
* @param LanguagePack $pack 방금 비활성화된 언어팩
*/
private function promoteSlotSuccessor(LanguagePack $pack): void
{
$candidates = $this->repository
->getPacksForSlot($pack->scope, $pack->target_identifier, $pack->locale)
->filter(fn (LanguagePack $p) => $p->id !== $pack->id && $p->status !== LanguagePackStatus::Error->value);
$next = $candidates->first();
if ($next) {
$this->repository->update($next, [
'status' => LanguagePackStatus::Active->value,
'activated_at' => now(),
]);
}
}
/**
* manifest → DB 컬럼 매핑 데이터를 빌드합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @param string $sourceType 설치 소스 유형
* @param string $sourceUrl 설치 소스 URL
* @param int|null $installedBy 설치자 사용자 ID
* @return array<string, mixed> Repository::create/update 입력 데이터
*/
private function buildPackData(array $manifest, string $sourceType, string $sourceUrl, ?int $installedBy): array
{
return [
'identifier' => $manifest['identifier'],
'vendor' => $manifest['vendor'],
'scope' => $manifest['scope'],
'target_identifier' => $manifest['target_identifier'] ?? null,
'locale' => $manifest['locale'],
'locale_name' => $manifest['locale_name'] ?? $manifest['locale'],
'locale_native_name' => $manifest['locale_native_name'],
'text_direction' => $manifest['text_direction'] ?? 'ltr',
'version' => $manifest['version'],
'target_version_constraint' => $manifest['requires']['target_version'] ?? null,
'target_version_mismatch' => $this->checkTargetVersionMismatch($manifest),
'license' => $manifest['license'] ?? null,
'description' => is_array($manifest['description'] ?? null) ? $manifest['description'] : null,
'is_protected' => false,
'manifest' => $manifest,
'source_type' => $sourceType,
'source_url' => $sourceUrl,
'installed_by' => $installedBy,
'installed_at' => now(),
];
}
/**
* 보안 규칙 검사 — 허용된 파일 형식·위치·내용만 통과시킵니다.
*
* 언어팩의 PHP 파일은 활성화되면 `LanguagePackTranslator` 가 `require` 하므로 그 내용이
* 그대로 실행된다. 과거에는 위험 함수 11개를 정규식으로 나열해 막았으나 목록에 없는
* 함수(`file_put_contents` `fopen` `call_user_func` `assert` 백틱 등)로 전부 우회됐다.
* 따라서 판정을 뒤집어, 아래 네 가지를 모두 만족하는 것만 통과시킨다.
*
* 1. 확장자 화이트리스트 — `.json` / `.php` / `.md` (대소문자 무관)
* 2. PHP 파일 위치 강제 — `backend/{manifest locale}/{group}.php` 또는 `backend/{group}.php`
* 3. 심볼릭 링크 거부 — 패키지 밖 파일을 끌어들이는 통로 차단
* 4. PHP 내용은 "번역 배열만" (LanguagePackPhpArrayValidator 문법 검사)
*
* 번들 여부와 무관하게 같은 경로로 검사한다 — 예외를 두면 "동봉된 것으로 위장하면 통과"
* 라는 틈이 생기고, 앞으로 언어팩을 만들 때 기준이 둘로 갈린다.
*
* @param string $packageRoot 패키지 루트 디렉토리
* @param array<string, mixed> $manifest manifest 데이터
*
* @throws RuntimeException 보안 위반 발견 시
*/
private function assertSecurityRules(string $packageRoot, array $manifest): void
{
$locale = (string) ($manifest['locale'] ?? '');
// `backend/{locale}/{group}.php` 와 평면형 `backend/{group}.php` 만 허용.
$phpLocationPattern = '#^backend/(?:'.preg_quote($locale, '#').'/)?[A-Za-z0-9_-]+\.php$#';
foreach ($this->collectPackageEntries($packageRoot) as $relative => $absolute) {
if (is_link($absolute)) {
throw new LanguagePackOperationException('language_packs.errors.symlink_not_allowed', [
'file' => $relative,
]);
}
if (is_dir($absolute)) {
continue;
}
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
if ($extension === 'php') {
if ($locale === '' || preg_match($phpLocationPattern, $relative) !== 1) {
throw new LanguagePackOperationException('language_packs.errors.php_outside_backend', [
'file' => $relative,
]);
}
$violation = LanguagePackPhpArrayValidator::inspectFile($absolute);
if ($violation !== null) {
throw new LanguagePackOperationException('language_packs.errors.php_file_not_literal_array', [
'file' => $relative,
'line' => $violation['line'],
]);
}
continue;
}
// 압축 도구가 끼워 넣는 OS 메타데이터는 형식 검사에서 제외한다.
// PHP 는 위 분기에서 이미 위치 규칙에 걸리므로 이 예외로 새는 실행 경로가 없다.
if ($this->isOsMetadataEntry($relative)) {
continue;
}
if (! in_array($extension, ['json', 'md'], true)) {
throw new LanguagePackOperationException('language_packs.errors.unsupported_file_type', [
'file' => $relative,
'extension' => $extension === '' ? '(none)' : $extension,
]);
}
}
}
/**
* 패키지 안의 모든 엔트리(디렉토리 포함)를 상대경로 ⇒ 절대경로로 수집합니다.
*
* 심볼릭 링크를 따라 들어가지 않는다 — 링크는 호출부가 거부 대상으로 판정하며,
* 따라 들어가면 순환 링크로 무한 재귀에 빠질 수 있다.
*
* @param string $packageRoot 패키지 루트 디렉토리
* @param string $prefix 현재 상대경로 접두사
* @return array<string, string> 상대경로(슬래시 구분) ⇒ 절대경로
*/
private function collectPackageEntries(string $packageRoot, string $prefix = ''): array
{
$base = $prefix === '' ? $packageRoot : $packageRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $prefix);
if (! is_dir($base)) {
return [];
}
$entries = [];
foreach (scandir($base) ?: [] as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$relative = $prefix === '' ? $name : $prefix.'/'.$name;
$absolute = $base.DIRECTORY_SEPARATOR.$name;
$entries[$relative] = $absolute;
if (is_dir($absolute) && ! is_link($absolute)) {
$entries += $this->collectPackageEntries($packageRoot, $relative);
}
}
return $entries;
}
/**
* 압축 도구가 생성하는 OS 메타데이터 엔트리인지 판정합니다.
*
* @param string $relative 패키지 루트 기준 상대경로
* @return bool 메타데이터면 true
*/
private function isOsMetadataEntry(string $relative): bool
{
if (str_starts_with($relative, '__MACOSX/') || $relative === '__MACOSX') {
return true;
}
return in_array(basename($relative), ['.DS_Store', 'Thumbs.db', '.gitkeep', '.gitignore'], true);
}
/**
* 언어팩 설치 디렉토리(`lang-packs/`, `lang-packs/_pending/`) 의 쓰기 권한을 사전 검증합니다.
*
* 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가
* 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다.
*
*
* @throws RuntimeException 디렉토리 미존재/쓰기 불가 시
*/
private function assertInstallDirectoriesWritable(): void
{
foreach (['lang-packs', 'lang-packs/_pending'] as $relative) {
$absolute = base_path($relative);
if (! File::isDirectory($absolute)) {
File::ensureDirectoryExists($absolute);
}
if (! is_writable($absolute)) {
throw new LanguagePackOperationException('language_packs.errors.directory_not_writable', [
'path' => $relative,
]);
}
}
}
/**
* 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인.
*
* @param array<string, mixed> $manifest manifest 데이터
*
* @throws RuntimeException 의존성 미충족 시
*/
private function assertDependencies(array $manifest): void
{
$reason = $this->resolveCoreLocaleBlockedReason($manifest);
if ($reason !== null) {
throw new LanguagePackOperationException('language_packs.errors.core_locale_missing', [
'locale' => $manifest['locale'],
]);
}
}
/**
* 대상 확장 존재 + 활성 여부를 확인합니다.
*
* 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
*
* @throws RuntimeException 대상 확장 미설치/비활성 시
*/
private function assertTargetExtensionExists(array $manifest): void
{
$reason = $this->resolveTargetExtensionBlockedReason($manifest);
if ($reason === 'target_not_installed') {
throw new LanguagePackOperationException('language_packs.errors.target_not_installed', [
'scope' => $manifest['scope'],
'target' => $manifest['target_identifier'] ?? '',
]);
}
if ($reason === 'target_inactive') {
throw new LanguagePackOperationException('language_packs.errors.target_inactive', [
'scope' => $manifest['scope'],
'target' => $manifest['target_identifier'] ?? '',
]);
}
if ($reason === 'target_version_too_old') {
$constraint = $manifest['requires']['target_version'] ?? '';
throw new LanguagePackOperationException('language_packs.errors.target_version_too_old', [
'scope' => $manifest['scope'],
'target' => $manifest['target_identifier'] ?? '',
'constraint' => (string) $constraint,
]);
}
}
/**
* 설치 차단 사유를 단일 키워드로 반환합니다 (UI 표시 + 차단 검증의 SSoT).
*
* 우선순위: 코어 locale 미활성 → 대상 미설치 → 대상 비활성 → 대상 버전 미달.
* 어느 것도 해당하지 않으면 null 을 반환합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return string|null 차단 사유 키 또는 null
*/
public function resolveInstallBlockedReason(array $manifest): ?string
{
return $this->resolveCoreLocaleBlockedReason($manifest)
?? $this->resolveTargetExtensionBlockedReason($manifest);
}
/**
* 코어 locale 의존성 차단 사유를 반환합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return string|null 'core_locale_missing' 또는 null
*/
private function resolveCoreLocaleBlockedReason(array $manifest): ?string
{
$scope = $manifest['scope'] ?? null;
if ($scope === LanguagePackScope::Core->value) {
return null;
}
$dependsOn = $manifest['requires']['depends_on_core_locale'] ?? true;
if (! $dependsOn) {
return null;
}
$locale = $manifest['locale'] ?? '';
if ($locale === '') {
return null;
}
return $this->registry->hasActiveCoreLocale($locale) ? null : 'core_locale_missing';
}
/**
* 대상 확장 존재/활성/버전 차단 사유를 반환합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return string|null 'target_not_installed' / 'target_inactive' / 'target_version_too_old' 또는 null
*/
private function resolveTargetExtensionBlockedReason(array $manifest): ?string
{
$scope = $manifest['scope'] ?? null;
$target = $manifest['target_identifier'] ?? null;
if ($scope === LanguagePackScope::Core->value || ! $target) {
return null;
}
$row = $this->repository->findHostExtensionRow($scope, $target);
if (! $row) {
return 'target_not_installed';
}
if (($row->status ?? null) !== 'active') {
return 'target_inactive';
}
$constraint = $manifest['requires']['target_version'] ?? null;
if ($constraint && $row->version) {
$cleaned = ltrim((string) $constraint, '^~>=<! ');
if (version_compare((string) $row->version, $cleaned, '<')) {
return 'target_version_too_old';
}
}
return null;
}
/**
* 다운그레이드 차단 검사.
*
* @param LanguagePack $existing 기존 언어팩
* @param array<string, mixed> $manifest 새 manifest
*
* @throws RuntimeException 다운그레이드 시도 시
*/
private function assertNotDowngrade(LanguagePack $existing, array $manifest): void
{
if (version_compare($manifest['version'], $existing->version, '<')) {
throw new LanguagePackOperationException('language_packs.errors.downgrade_blocked', [
'from' => $existing->version,
'to' => $manifest['version'],
]);
}
}
/**
* target_version 불일치 여부를 계산합니다 (경고 플래그용, 차단하지 않음).
*
* @param array<string, mixed> $manifest manifest 데이터
* @return bool 불일치 시 true
*/
private function checkTargetVersionMismatch(array $manifest): bool
{
$scope = $manifest['scope'];
$target = $manifest['target_identifier'] ?? null;
$constraint = $manifest['requires']['target_version'] ?? null;
if ($scope === LanguagePackScope::Core->value || ! $target || ! $constraint) {
return false;
}
$row = $this->repository->findHostExtensionRow($scope, $target);
if (! $row || ! $row->version) {
return false;
}
// 단순 정확 일치 여부만 본다 (Composer 제약 평가는 별도 비용 — v1 범위 밖)
$cleaned = ltrim($constraint, '^~>=<! ');
return version_compare((string) $row->version, $cleaned, '<');
}
/**
* _pending 임시 디렉토리를 정리합니다.
*
* @param string $path 정리 대상 경로
*/
private function cleanupPending(string $path): void
{
try {
if (File::isDirectory($path)) {
File::deleteDirectory($path);
}
} catch (Throwable $e) {
Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]);
}
}
/**
* GitHub 소스 언어팩의 latest_version 을 갱신합니다.
*
* @param string|null $identifier 특정 언어팩만 확인 (옵션)
* @return array{checked:int, updates:int, details:array<int, array<string, mixed>>}
*/
public function checkUpdates(?string $identifier = null): array
{
// 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
// 전량 순회는 allForUpdateCheck() — paginate 는 HTTP page 파라미터를 암묵 해석해
// `?page=2` 요청에서 순회가 0건이 된다 (공개 이슈 #102 동형)
$packs = $identifier
? collect([$this->repository->findByIdentifier($identifier)])->filter()
: $this->repository->allForUpdateCheck();
$checked = 0;
$updates = 0;
$details = [];
foreach ($packs as $pack) {
// GitHub URL 해석 가능 (manifest.github_url 또는 source_url) 또는
// bundled 폴백 가능한 팩만 점검 대상.
if ($this->resolveGithubUrl($pack) === null && ! $this->hasBundledManifest($pack->identifier)) {
continue;
}
$checked++;
$entry = $this->checkSinglePackUpdate($pack);
$details[] = $entry;
if ($entry['has_update']) {
$updates++;
}
}
return ['checked' => $checked, 'updates' => $updates, 'details' => $details];
}
/**
* 단일 GitHub 언어팩의 최신 버전을 조회하고 latest_version 을 갱신합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @return array{identifier:string, current:string, latest:string|null, has_update:bool, error:string|null}
*/
private function checkSinglePackUpdate(LanguagePack $pack): array
{
$latest = null;
$error = null;
// 1순위: GitHub (manifest.github_url SSoT — 모듈/플러그인/템플릿과 동일 패턴.
// 매니페스트에 없으면 source_url 이 GitHub URL 인 경우 폴백)
$githubUrl = $this->resolveGithubUrl($pack);
if ($githubUrl !== null) {
try {
['owner' => $owner, 'repo' => $repo] = GithubHelper::parseUrl($githubUrl);
$release = GithubHelper::fetchLatestRelease($owner, $repo);
$latest = ltrim((string) ($release['tag_name'] ?? ''), 'v') ?: null;
} catch (Throwable $e) {
$error = $e->getMessage();
Log::warning('language-pack github update check failed', [
'identifier' => $pack->identifier,
'error' => $error,
]);
}
}
// 2순위: bundled 폴백 (lang-packs/_bundled/{identifier}/language-pack.json)
if ($latest === null) {
$latest = $this->resolveBundledLatestVersion($pack);
}
if ($latest === null) {
return [
'identifier' => $pack->identifier,
'current' => $pack->version,
'latest' => null,
'has_update' => false,
'error' => $error,
];
}
$this->repository->update($pack, ['latest_version' => $latest]);
return [
'identifier' => $pack->identifier,
'current' => $pack->version,
'latest' => $latest,
'has_update' => version_compare($latest, $pack->version, '>'),
'error' => null,
];
}
/**
* 언어팩의 GitHub repository URL 을 해석합니다.
*
* 우선순위 (모듈/플러그인/템플릿의 `github_url` SSoT 패턴과 일관):
* 1. `manifest.github_url` (DB `manifest` 컬럼에 스냅샷된 매니페스트 필드)
* 2. `source_url` 이 `https://github.com/` 로 시작 (GitHub 설치 경로 호환)
*
* 둘 다 없거나 GitHub URL 이 아니면 null.
*
* @param LanguagePack $pack 대상 언어팩
* @return string|null GitHub URL 또는 null
*/
private function resolveGithubUrl(LanguagePack $pack): ?string
{
$manifest = $pack->manifest;
if (is_array($manifest)) {
$manifestUrl = $manifest['github_url'] ?? null;
if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) {
return $manifestUrl;
}
}
$sourceUrl = (string) $pack->source_url;
if (self::isGithubUrl($sourceUrl)) {
return $sourceUrl;
}
return null;
}
/**
* URL 이 실제 GitHub host 를 가리키는지 판정합니다.
*
* 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로
* host 를 완전 일치로 검증합니다.
*
* @param string $url 판정 대상 URL
* @return bool GitHub URL 이면 true
*/
private static function isGithubUrl(string $url): bool
{
return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']);
}
/**
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
*
* @param LanguagePack $pack 대상 언어팩
* @return string|null 최신 버전 문자열 또는 null
*/
private function resolveBundledLatestVersion(LanguagePack $pack): ?string
{
$manifestPath = base_path('lang-packs/_bundled/'.$pack->identifier.'/language-pack.json');
if (! is_file($manifestPath)) {
return null;
}
$raw = @file_get_contents($manifestPath);
$manifest = $raw ? json_decode($raw, true) : null;
if (! is_array($manifest) || empty($manifest['version'])) {
return null;
}
return (string) $manifest['version'];
}
/**
* 번들 manifest 가 존재하는지 확인합니다.
*
* @param string $identifier 언어팩 식별자
* @return bool 번들 디렉토리가 있고 manifest 가 유효하면 true
*/
private function hasBundledManifest(string $identifier): bool
{
return is_file(base_path('lang-packs/_bundled/'.$identifier.'/language-pack.json'));
}
/**
* `lang-packs/_bundled/` 의 신버전 manifest 와 DB 의 설치된 언어팩 버전을 직접 비교하여
* 업데이트 가능한 언어팩 목록을 수집합니다.
*
* `core:update` 의 `runBundledExtensionUpdatePrompt` 가 사용. GitHub 검사를 우회하여
* `_bundled` 만으로 업데이트 후보를 빠르게 판정 (모듈/플러그인/템플릿의
* `CoreUpdateService::collectBundledExtensionUpdates()` 와 동일 패턴).
*
* @return array<int, array{identifier: string, current_version: string, latest_version: string}>
*/
public function collectBundledLangPackUpdates(): array
{
$updates = [];
$bundledRoot = base_path('lang-packs/_bundled');
if (! is_dir($bundledRoot)) {
return $updates;
}
// DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨).
// 전량 순회는 allForUpdateCheck() — paginate 의 암묵 page 해석 회피 (#102 동형)
$packs = $this->repository->allForUpdateCheck();
foreach ($packs as $pack) {
if (! $this->hasBundledManifest($pack->identifier)) {
continue;
}
$manifestPath = $bundledRoot.'/'.$pack->identifier.'/language-pack.json';
try {
$manifest = json_decode((string) file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable) {
continue;
}
$bundledVersion = (string) ($manifest['version'] ?? '');
$currentVersion = (string) $pack->version;
if ($bundledVersion === '' || $currentVersion === '') {
continue;
}
// 시맨틱 버전 비교: 번들이 더 크면 업데이트 후보
if (version_compare($bundledVersion, $currentVersion, '>')) {
$updates[] = [
'identifier' => $pack->identifier,
'current_version' => $currentVersion,
'latest_version' => $bundledVersion,
];
}
}
return $updates;
}
/**
* GitHub 소스 언어팩의 신버전을 다운로드하여 적용합니다.
*
* 모듈/플러그인/템플릿 update 플로우와 동일한 깊이의 안전 장치 적용:
* - 동시성 가드 (status=Updating 상태에서 재진입 차단)
* - 백업 + 실패 시 자동 롤백 (ExtensionBackupHelper)
* - 이전 상태 복원 보장 (Active/Inactive)
* - before/after hooks: core.language_packs.updated (성공 시)
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $force 버전 변동 가드 우회 여부 (true 면 같은 버전으로도 _bundled 강제 재적용)
* @return LanguagePack 갱신된 언어팩
*
* @throws LanguagePackOperationException source_type 이 github 이 아니거나 source_url 이 없는 경우, 또는 진행 중일 때
*/
public function performUpdate(LanguagePack $pack, bool $force = false): LanguagePack
{
// 요구사항 #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백
$githubUrl = $this->resolveGithubUrl($pack);
if (! $githubUrl && ! $this->hasBundledManifest($pack->identifier)) {
throw new LanguagePackOperationException('language_packs.errors.update_no_source');
}
$this->assertNotInProgress($pack);
// force=false 일 때만 버전 변동 여부를 가드한다. force=true 는 모듈/플러그인/템플릿의
// {type}:update --force 와 동일하게 버전이 같아도 _bundled 에서 강제 재적용을 허용한다.
if (! $force) {
$check = $this->checkSinglePackUpdate($pack);
if (! $check['has_update']) {
throw new LanguagePackOperationException('language_packs.errors.update_already_latest');
}
}
$identifier = $pack->identifier;
$previousStatus = $pack->status;
$autoActivate = $pack->isActive();
$backupPath = null;
// 우선순위 결정: force=true + bundled 존재 → bundled, 그 외 → GitHub (manifest.github_url 우선)
$useBundled = $force && $this->hasBundledManifest($identifier);
if (! $useBundled && $githubUrl === null) {
// GitHub URL 해석 실패 시 bundled 폴백 자동 사용
$useBundled = $this->hasBundledManifest($identifier);
}
// 1. 상태 → Updating (가드용)
$this->repository->update($pack, [
'status' => LanguagePackStatus::Updating->value,
]);
try {
// 2. 활성 디렉토리 백업 (롤백 대비)
$activeDir = base_path('lang-packs/'.$identifier);
if (File::isDirectory($activeDir)) {
$backupPath = ExtensionBackupHelper::createBackup('lang-packs', $identifier);
}
// 3. 신버전 다운로드 + 적용 (force 전파 — 다운그레이드 우회 허용)
if ($useBundled) {
$updated = $this->installFromBundled($identifier, $autoActivate, $pack->installed_by, $force);
} else {
$updated = $this->installFromGithub((string) $githubUrl, $autoActivate, $pack->installed_by, $force);
}
// 4. 백업 정리
if ($backupPath) {
ExtensionBackupHelper::deleteBackup($backupPath);
}
return $updated;
} catch (Throwable $e) {
Log::error('language pack update failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
// 백업 복원
if ($backupPath) {
try {
ExtensionBackupHelper::restoreFromBackup('lang-packs', $identifier, $backupPath);
ExtensionBackupHelper::deleteBackup($backupPath);
} catch (Throwable $restoreError) {
Log::error('language pack backup restore failed', [
'identifier' => $identifier,
'error' => $restoreError->getMessage(),
]);
}
}
// 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다.
// Updating: 설치 트랜잭션 이전에 실패한 경우.
// Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 —
// 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의
// 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측).
// 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다.
try {
$current = $this->repository->findByIdentifier($identifier);
$restorable = [
LanguagePackStatus::Updating->value,
LanguagePackStatus::Installed->value,
];
if ($current
&& $current->status !== $previousStatus
&& in_array($current->status, $restorable, true)) {
$this->repository->update($current, ['status' => $previousStatus]);
}
} catch (Throwable $statusError) {
Log::error('language pack status restore failed', [
'identifier' => $identifier,
'error' => $statusError->getMessage(),
]);
}
// 백업 복원으로 디스크 상태가 바뀌었으므로 전 계층 캐시 갱신 + 프론트 cache busting
$this->refreshCache();
throw $e;
}
}
/**
* 진행 중인 작업이 없는지 확인합니다.
*
* 모듈/플러그인/템플릿의 ExtensionStatusGuard 와 동일한 의미의 가드.
* status=Updating 인 팩에 대해 활성/제거/재업데이트 진입을 차단합니다.
*
* @param LanguagePack $pack 대상 언어팩
*
* @throws RuntimeException 진행 중일 때
*/
public function assertNotInProgress(LanguagePack $pack): void
{
if ($pack->status === LanguagePackStatus::Updating->value) {
throw new LanguagePackOperationException('language_packs.errors.operation_in_progress', [
'identifier' => $pack->identifier,
]);
}
}
/**
* 번역/레지스트리/템플릿 언어 캐시를 무효화합니다.
*
* 추가로 `ext.cache_version` 을 bump 하여 프론트엔드의 lang/routes/layout fetch URL
* 에 부착되는 `?v=` 쿼리 파라미터가 새 값으로 갱신되도록 한다 — 모듈/플러그인/템플릿
* 활성화와 동일한 cache busting 메커니즘.
*
* @return array{registry:bool, translator:bool, template:bool, version:bool}
*/
public function refreshCache(): array
{
$result = ['registry' => false, 'translator' => false, 'template' => false, 'version' => false];
try {
$this->registry->invalidate();
$result['registry'] = true;
} catch (Throwable $e) {
Log::warning('language-pack registry invalidate failed', ['error' => $e->getMessage()]);
}
try {
$translator = app('translator');
if (method_exists($translator, 'setLoaded')) {
$translator->setLoaded([]);
}
$result['translator'] = true;
} catch (Throwable $e) {
Log::warning('language-pack translator reset failed', ['error' => $e->getMessage()]);
}
try {
if ($this->cache->supportsTags()) {
$this->cache->flushTags(['template-lang']);
} else {
$this->cache->forget('template-lang');
}
$result['template'] = true;
} catch (Throwable $e) {
Log::warning('language-pack template cache flush failed', ['error' => $e->getMessage()]);
}
try {
// 프론트엔드 cache busting — `?v=` 쿼리 파라미터가 새 타임스탬프로 갱신됨
$this->incrementExtensionCacheVersion();
$result['version'] = true;
} catch (Throwable $e) {
Log::warning('language-pack extension cache version bump failed', ['error' => $e->getMessage()]);
}
return $result;
}
/**
* 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
*
* @param UploadedFile $file 업로드된 ZIP
* @return array{manifest:array<string, mixed>, validation:array<string, mixed>}
*
* @throws RuntimeException manifest 추출/검증 실패 시
*/
public function previewManifest(UploadedFile $file): array
{
$tempId = (string) Str::uuid();
$extractPath = base_path('lang-packs/_pending/preview-'.$tempId);
try {
$zipPath = $file->getRealPath();
ZipInstallHelper::extractZip($zipPath, $extractPath);
$manifestPath = ZipInstallHelper::findManifest($extractPath, 'language-pack.json');
if (! $manifestPath) {
throw new LanguagePackOperationException('language_packs.errors.manifest_not_found');
}
$manifestRaw = File::get($manifestPath);
$manifest = json_decode($manifestRaw, true);
if (! is_array($manifest)) {
throw new LanguagePackOperationException('language_packs.errors.manifest_invalid_json');
}
$packageRoot = dirname($manifestPath);
$validationErrors = [];
try {
$this->validator->validate($manifest, $packageRoot);
} catch (Throwable $e) {
$validationErrors[] = $e->getMessage();
}
$existing = $this->repository->findByIdentifier($manifest['identifier'] ?? '');
return [
'manifest' => $manifest,
'validation' => [
'errors' => $validationErrors,
'is_valid' => $validationErrors === [],
'already_installed' => $existing !== null,
'existing_version' => $existing?->version,
'target_version_mismatch' => $this->checkTargetVersionMismatch($manifest),
],
];
} finally {
$this->cleanupPending($extractPath);
}
}
}