From ce8c6031803c33696df162b72aba70983262e709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:23:55 +0000 Subject: [PATCH] refactor: clean up extension install/update/uninstall system - Extract shared archive helpers into ExtensionPackageArtifactService - Create ExtensionPackageFileService for snapshot/validation helpers - Simplify Install/Update/UninstallService by injecting shared services - Create HandlesExtensionPackages trait for commands - Slim Install/Update/UninstallExtensionCommand via shared trait - Fix batch service finally blocks to repair ownership per extension - Fix ExtensionInstallProgressService: explicit uninstall arm + throw on unknown - Simplify ExtensionOperationLockService blocked message builder Agent-Logs-Url: https://github.com/macery12/M12Labs/sessions/ae2f180c-c7a2-4741-a6e9-967921fe1477 Co-authored-by: macery12 <57544649+macery12@users.noreply.github.com> --- .../Concerns/HandlesExtensionPackages.php | 294 +++++++++++++++++ .../Extensions/InstallExtensionCommand.php | 279 +--------------- .../Extensions/UninstallExtensionCommand.php | 24 +- .../Extensions/UpdateExtensionCommand.php | 277 +--------------- .../ExtensionInstallProgressService.php | 11 +- .../ExtensionOperationLockService.php | 35 +- .../ExtensionPackageArtifactService.php | 138 +++++++- .../ExtensionPackageBatchService.php | 4 + .../ExtensionPackageFileService.php | 97 ++++++ .../ExtensionPackageInstallService.php | 193 +---------- .../ExtensionPackageUninstallService.php | 85 +---- .../ExtensionPackageUpdateService.php | 310 +++--------------- 12 files changed, 631 insertions(+), 1116 deletions(-) create mode 100644 app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php create mode 100644 app/Services/Extensions/ExtensionPackageFileService.php diff --git a/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php b/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php new file mode 100644 index 000000000..71b779773 --- /dev/null +++ b/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php @@ -0,0 +1,294 @@ + + */ + protected function resolveResolution(string $source, string $action): array + { + /** @var ExtensionPackageArtifactService $artifactService */ + $artifactService = app(ExtensionPackageArtifactService::class); + + $cwd = getcwd() ?: base_path(); + $discoveredArtifacts = $artifactService->discoverArchives($cwd); + $validArtifacts = array_values(array_filter($discoveredArtifacts, fn (array $a): bool => !isset($a['error']))); + $explicitPath = $this->option('path') ? trim((string) $this->option('path')) : null; + + if ($explicitPath !== null && $explicitPath !== '') { + return $this->createFileResolution($explicitPath, $cwd, $discoveredArtifacts, $source); + } + + if ($this->option('file')) { + return $this->resolveFileModeSelection($source, $cwd, $discoveredArtifacts, $validArtifacts, $action); + } + + if ($source !== '' && $artifactService->looksLikeArchiveReference($source, $cwd)) { + return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); + } + + if ($source !== '') { + $matchingLocal = array_values(array_filter( + $validArtifacts, + fn (array $a): bool => ($a['extensionId'] ?? null) === $source + )); + + if (count($matchingLocal) === 1) { + if ($this->option('yes') || $this->confirm(sprintf( + 'Found local package %s (%s) in %s. %s from that file instead of using the repository?', + $matchingLocal[0]['name'], + $matchingLocal[0]['version'], + $cwd, + ucfirst($action) + ), true)) { + return $this->createFileResolution($matchingLocal[0]['archivePath'], $cwd, $discoveredArtifacts, $source); + } + } elseif ($validArtifacts !== [] && !$this->option('yes')) { + $choice = $this->choice( + sprintf('Found %d local extension package file(s) in %s while you asked for "%s". What do you want to do?', count($validArtifacts), $cwd, $source), + ['Use repository ' . $action, 'Select a discovered package', 'Enter a path', 'Cancel'], + 'Use repository ' . $action + ); + + if ($choice === 'Select a discovered package') { + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, $source, $action); + } + + if ($choice === 'Enter a path') { + $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); + + return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $source); + } + + if ($choice === 'Cancel') { + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + } + } + + if ($source === '') { + if ($validArtifacts === []) { + throw new DisplayException('No extension id or local package file was provided. Run this command in a directory containing a .M12LabsExtension file, pass --path, or provide an extension id.'); + } + + if (count($validArtifacts) === 1) { + if ($this->option('yes') || $this->confirm(sprintf( + ucfirst($action) . ' from the local package %s (%s) found in %s?', + $validArtifacts[0]['name'], + $validArtifacts[0]['version'], + $cwd, + ), true)) { + return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); + } + + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + + if ($this->option('yes')) { + throw new DisplayException(sprintf( + 'Multiple local extension packages were found. Re-run without --yes to select one, or pass --path explicitly.' + )); + } + + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null, $action); + } + + $repository = $this->resolveRepository($this->option('repository')); + + return [ + 'mode' => 'repository', + 'extensionId' => $source, + 'repository' => $repository, + 'release' => $this->option('release') ? trim((string) $this->option('release')) : null, + 'discoveredArtifacts' => $discoveredArtifacts, + 'cwd' => $cwd, + ]; + } + + /** + * @param array> $discoveredArtifacts + * @param array> $validArtifacts + * @return array + */ + private function resolveFileModeSelection(string $source, string $cwd, array $discoveredArtifacts, array $validArtifacts, string $action): array + { + if ($source !== '') { + return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); + } + + if ($validArtifacts === []) { + throw new DisplayException(sprintf('No local .M12LabsExtension files were found in the current directory. Pass a path or omit --file to %s from a repository.', $action)); + } + + if (count($validArtifacts) === 1) { + return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); + } + + if ($this->option('yes')) { + throw new DisplayException('Multiple local extension packages were found. Pass --path or re-run without --yes to choose one interactively.'); + } + + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null, $action); + } + + /** + * @param array> $validArtifacts + * @param array> $discoveredArtifacts + * @return array + */ + private function selectDiscoveredArtifact(array $validArtifacts, string $cwd, array $discoveredArtifacts, ?string $requestedSource, string $action): array + { + $choices = []; + foreach ($validArtifacts as $index => $artifact) { + $choices[] = sprintf('%d. %s (%s) [%s]', $index + 1, $artifact['name'], $artifact['version'], $artifact['archiveName']); + } + + $choices[] = 'Enter a path'; + $choices[] = 'Cancel'; + + $choice = $this->choice( + sprintf('Found %d local extension package file(s) in %s. Select one to %s from.', count($validArtifacts), $cwd, $action), + $choices, + $choices[0] + ); + + if ($choice === 'Enter a path') { + $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); + + return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $requestedSource); + } + + if ($choice === 'Cancel') { + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + + $selectedIndex = max(0, ((int) Str::before($choice, '.')) - 1); + $selectedArtifact = $validArtifacts[$selectedIndex] ?? null; + if (!$selectedArtifact) { + throw new DisplayException('Unable to resolve the selected local package.'); + } + + return $this->createFileResolution($selectedArtifact['archivePath'], $cwd, $discoveredArtifacts, $requestedSource ?: $selectedArtifact['extensionId']); + } + + /** + * @param array> $discoveredArtifacts + * @return array + */ + private function createFileResolution(string $path, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array + { + /** @var ExtensionPackageArtifactService $artifactService */ + $artifactService = app(ExtensionPackageArtifactService::class); + $artifact = $artifactService->inspectArchive($path, $cwd); + + return [ + 'mode' => 'file', + 'extensionId' => $artifact['extensionId'], + 'archivePath' => $artifact['archivePath'], + 'label' => $this->option('label') ? trim((string) $this->option('label')) : sprintf('Manual package file (%s)', $artifact['archiveName']), + 'artifact' => $artifact, + 'requestedSource' => $requestedSource, + 'discoveredArtifacts' => $discoveredArtifacts, + 'cwd' => $cwd, + ]; + } + + /** + * @param array $resolution + */ + protected function renderDebugResolution(array $resolution): void + { + $this->newLine(); + $this->components->twoColumnDetail('Mode', (string) $resolution['mode']); + $this->components->twoColumnDetail('Working directory', (string) ($resolution['cwd'] ?? base_path())); + + if (!empty($resolution['discoveredArtifacts'])) { + $rows = []; + foreach ($resolution['discoveredArtifacts'] as $artifact) { + $rows[] = [ + $artifact['extensionId'] ?? 'invalid', + $artifact['version'] ?? '-', + $artifact['archiveName'] ?? basename((string) ($artifact['archivePath'] ?? 'unknown')), + $artifact['error'] ?? 'ok', + ]; + } + + $this->table(['Extension', 'Version', 'Artifact', 'Status'], $rows); + } + + if ($resolution['mode'] === 'file' && isset($resolution['artifact'])) { + $artifact = $resolution['artifact']; + $this->table(['Field', 'Value'], [ + ['Extension', $artifact['extensionId']], + ['Version', $artifact['version']], + ['Name', $artifact['name']], + ['Archive', $artifact['archivePath']], + ['Files', (string) $artifact['fileCount']], + ]); + + return; + } + + /** @var ExtensionRepository $repository */ + $repository = $resolution['repository']; + $this->table(['Field', 'Value'], [ + ['Extension', $resolution['extensionId']], + ['Repository', $repository->name], + ['Repository slug', $repository->slug], + ['Release', $resolution['release'] ?? 'latest'], + ]); + } + + protected function renderDebugException(\Throwable $exception): void + { + $this->newLine(); + $this->line(sprintf('Debug: %s', $exception::class)); + + $previous = $exception->getPrevious(); + while ($previous) { + $this->line(sprintf('Caused by: %s - %s', $previous::class, $previous->getMessage())); + $previous = $previous->getPrevious(); + } + } + + /** + * @param array $report + */ + protected function renderOwnershipReport(array $report): void + { + if ($report === [] || empty($report['paths'])) { + return; + } + + $this->newLine(); + $this->components->info(sprintf( + 'Repaired ownership for extension paths using %s:%s (%s).', + $report['user'], + $report['group'], + $report['sourcePath'] + )); + + if ($this->isDebug()) { + $this->table(['Path'], array_map(fn (string $path) => [$path], $report['paths'])); + } + } + + protected function isDebug(): bool + { + return (bool) $this->option('debug'); + } +} diff --git a/app/Console/Commands/Extensions/InstallExtensionCommand.php b/app/Console/Commands/Extensions/InstallExtensionCommand.php index f0ac809ca..9d3f68d2c 100644 --- a/app/Console/Commands/Extensions/InstallExtensionCommand.php +++ b/app/Console/Commands/Extensions/InstallExtensionCommand.php @@ -2,18 +2,16 @@ namespace Everest\Console\Commands\Extensions; +use Everest\Console\Commands\Extensions\Concerns\HandlesExtensionPackages; use Everest\Console\Commands\Extensions\Concerns\InteractsWithExtensionRepositories; -use Everest\Exceptions\DisplayException; use Everest\Models\ExtensionRepository; use Everest\Services\Extensions\ExtensionFilesystemOwnershipService; -use Everest\Services\Extensions\ExtensionPackageArtifactService; use Everest\Services\Extensions\ExtensionPackageInstallService; use Illuminate\Console\Command; -use Illuminate\Support\Str; class InstallExtensionCommand extends Command { - use InteractsWithExtensionRepositories; + use HandlesExtensionPackages, InteractsWithExtensionRepositories; protected $signature = 'p:extensions:install {source? : Extension id from a configured repository, or a local package file path} @@ -29,7 +27,6 @@ class InstallExtensionCommand extends Command public function __construct( private ExtensionPackageInstallService $installService, - private ExtensionPackageArtifactService $artifactService, private ExtensionFilesystemOwnershipService $ownershipService ) { parent::__construct(); @@ -41,7 +38,7 @@ public function handle(): int $resolution = null; try { - $resolution = $this->resolveInstallResolution($source); + $resolution = $this->resolveResolution($source, 'install'); if ($this->isDebug()) { $this->renderDebugResolution($resolution); @@ -87,272 +84,4 @@ public function handle(): int return self::SUCCESS; } - - /** - * @return array - */ - protected function resolveInstallResolution(string $source): array - { - $cwd = getcwd() ?: base_path(); - $discoveredArtifacts = $this->artifactService->discoverArchives($cwd); - $validArtifacts = array_values(array_filter($discoveredArtifacts, fn (array $artifact): bool => !isset($artifact['error']))); - $explicitPath = $this->option('path') ? trim((string) $this->option('path')) : null; - - if ($explicitPath !== null && $explicitPath !== '') { - return $this->createFileResolution($explicitPath, $cwd, $discoveredArtifacts, $source); - } - - if ($this->option('file')) { - return $this->resolveFileModeSelection($source, $cwd, $discoveredArtifacts, $validArtifacts); - } - - if ($source !== '' && $this->artifactService->looksLikeArchiveReference($source, $cwd)) { - return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); - } - - if ($source !== '') { - $matchingLocal = array_values(array_filter( - $validArtifacts, - fn (array $artifact): bool => ($artifact['extensionId'] ?? null) === $source - )); - - if (count($matchingLocal) === 1) { - if ($this->option('yes') || $this->confirm(sprintf( - 'Found local package %s (%s) in %s. Install that file instead of using the repository?', - $matchingLocal[0]['name'], - $matchingLocal[0]['version'], - $cwd, - ), true)) { - return $this->createFileResolution($matchingLocal[0]['archivePath'], $cwd, $discoveredArtifacts, $source); - } - } elseif ($validArtifacts !== [] && !$this->option('yes')) { - $choice = $this->choice( - sprintf('Found %d local extension package file(s) in %s while you asked for "%s". What do you want to do?', count($validArtifacts), $cwd, $source), - ['Use repository install', 'Select a discovered package', 'Enter a path', 'Cancel'], - 'Use repository install' - ); - - if ($choice === 'Select a discovered package') { - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, $source); - } - - if ($choice === 'Enter a path') { - $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); - - return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $source); - } - - if ($choice === 'Cancel') { - throw new DisplayException('Installation cancelled.'); - } - } - } - - if ($source === '') { - if ($validArtifacts === []) { - throw new DisplayException('No extension id or local package file was provided. Run this command in a directory containing a .M12LabsExtension file, pass --path, or provide an extension id.'); - } - - if (count($validArtifacts) === 1) { - if ($this->option('yes') || $this->confirm(sprintf( - 'Install the local package %s (%s) found in %s?', - $validArtifacts[0]['name'], - $validArtifacts[0]['version'], - $cwd, - ), true)) { - return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); - } - - throw new DisplayException('Installation cancelled.'); - } - - if ($this->option('yes')) { - throw new DisplayException('Multiple local extension packages were found. Re-run without --yes to select one, or pass --path explicitly.'); - } - - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null); - } - - $repository = $this->resolveRepository($this->option('repository')); - - return [ - 'mode' => 'repository', - 'extensionId' => $source, - 'repository' => $repository, - 'release' => $this->option('release') ? trim((string) $this->option('release')) : null, - 'discoveredArtifacts' => $discoveredArtifacts, - 'cwd' => $cwd, - ]; - } - - /** - * @param array> $discoveredArtifacts - * @param array> $validArtifacts - * @return array - */ - private function resolveFileModeSelection(string $source, string $cwd, array $discoveredArtifacts, array $validArtifacts): array - { - if ($source !== '') { - return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); - } - - if ($validArtifacts === []) { - throw new DisplayException('No local .M12LabsExtension files were found in the current directory. Pass a path or omit --file to install from a repository.'); - } - - if (count($validArtifacts) === 1) { - return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); - } - - if ($this->option('yes')) { - throw new DisplayException('Multiple local extension packages were found. Pass --path or re-run without --yes to choose one interactively.'); - } - - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null); - } - - /** - * @param array> $validArtifacts - * @param array> $discoveredArtifacts - * @return array - */ - private function selectDiscoveredArtifact(array $validArtifacts, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array - { - $choices = []; - foreach ($validArtifacts as $index => $artifact) { - $choices[] = sprintf('%d. %s (%s) [%s]', $index + 1, $artifact['name'], $artifact['version'], $artifact['archiveName']); - } - - $choices[] = 'Enter a path'; - $choices[] = 'Cancel'; - - $choice = $this->choice( - sprintf('Found %d local extension package file(s) in %s. Select one to install.', count($validArtifacts), $cwd), - $choices, - $choices[0] - ); - - if ($choice === 'Enter a path') { - $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); - - return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $requestedSource); - } - - if ($choice === 'Cancel') { - throw new DisplayException('Installation cancelled.'); - } - - $selectedIndex = max(0, ((int) Str::before($choice, '.')) - 1); - $selectedArtifact = $validArtifacts[$selectedIndex] ?? null; - if (!$selectedArtifact) { - throw new DisplayException('Unable to resolve the selected local package.'); - } - - return $this->createFileResolution($selectedArtifact['archivePath'], $cwd, $discoveredArtifacts, $requestedSource ?: $selectedArtifact['extensionId']); - } - - /** - * @param array> $discoveredArtifacts - * @return array - */ - private function createFileResolution(string $path, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array - { - $artifact = $this->artifactService->inspectArchive($path, $cwd); - - return [ - 'mode' => 'file', - 'extensionId' => $artifact['extensionId'], - 'archivePath' => $artifact['archivePath'], - 'label' => $this->option('label') ? trim((string) $this->option('label')) : sprintf('Manual package file (%s)', $artifact['archiveName']), - 'artifact' => $artifact, - 'requestedSource' => $requestedSource, - 'discoveredArtifacts' => $discoveredArtifacts, - 'cwd' => $cwd, - ]; - } - - /** - * @param array $resolution - */ - private function renderDebugResolution(array $resolution): void - { - $this->newLine(); - $this->components->twoColumnDetail('Mode', (string) $resolution['mode']); - $this->components->twoColumnDetail('Working directory', (string) ($resolution['cwd'] ?? base_path())); - - if (!empty($resolution['discoveredArtifacts'])) { - $rows = []; - foreach ($resolution['discoveredArtifacts'] as $artifact) { - $rows[] = [ - $artifact['extensionId'] ?? 'invalid', - $artifact['version'] ?? '-', - $artifact['archiveName'] ?? basename((string) ($artifact['archivePath'] ?? 'unknown')), - $artifact['error'] ?? 'ok', - ]; - } - - $this->table(['Extension', 'Version', 'Artifact', 'Status'], $rows); - } - - if ($resolution['mode'] === 'file' && isset($resolution['artifact'])) { - $artifact = $resolution['artifact']; - $this->table(['Field', 'Value'], [ - ['Extension', $artifact['extensionId']], - ['Version', $artifact['version']], - ['Name', $artifact['name']], - ['Archive', $artifact['archivePath']], - ['Files', (string) $artifact['fileCount']], - ]); - - return; - } - - /** @var ExtensionRepository $repository */ - $repository = $resolution['repository']; - $this->table(['Field', 'Value'], [ - ['Extension', $resolution['extensionId']], - ['Repository', $repository->name], - ['Repository slug', $repository->slug], - ['Release', $resolution['release'] ?? 'latest'], - ]); - } - - private function renderDebugException(\Throwable $exception): void - { - $this->newLine(); - $this->line(sprintf('Debug: %s', $exception::class)); - - $previous = $exception->getPrevious(); - while ($previous) { - $this->line(sprintf('Caused by: %s - %s', $previous::class, $previous->getMessage())); - $previous = $previous->getPrevious(); - } - } - - /** - * @param array $report - */ - private function renderOwnershipReport(array $report): void - { - if ($report === [] || empty($report['paths'])) { - return; - } - - $this->newLine(); - $this->components->info(sprintf( - 'Repaired ownership for extension paths using %s:%s (%s).', - $report['user'], - $report['group'], - $report['sourcePath'] - )); - - if ($this->isDebug()) { - $this->table(['Path'], array_map(fn (string $path) => [$path], $report['paths'])); - } - } - - protected function isDebug(): bool - { - return (bool) $this->option('debug'); - } -} \ No newline at end of file +} diff --git a/app/Console/Commands/Extensions/UninstallExtensionCommand.php b/app/Console/Commands/Extensions/UninstallExtensionCommand.php index 314b6254f..f7a01f1a2 100644 --- a/app/Console/Commands/Extensions/UninstallExtensionCommand.php +++ b/app/Console/Commands/Extensions/UninstallExtensionCommand.php @@ -2,12 +2,16 @@ namespace Everest\Console\Commands\Extensions; +use Everest\Console\Commands\Extensions\Concerns\HandlesExtensionPackages; +use Everest\Console\Commands\Extensions\Concerns\InteractsWithExtensionRepositories; use Everest\Services\Extensions\ExtensionFilesystemOwnershipService; use Everest\Services\Extensions\ExtensionPackageUninstallService; use Illuminate\Console\Command; class UninstallExtensionCommand extends Command { + use HandlesExtensionPackages, InteractsWithExtensionRepositories; + protected $signature = 'p:extensions:uninstall {extensionId : Installed extension id to remove} {--force : Skip the confirmation prompt} @@ -37,25 +41,15 @@ public function handle(): int } catch (\Throwable $exception) { $this->components->error($exception->getMessage()); - if ((bool) $this->option('debug')) { - $this->line(sprintf('Debug: %s', $exception::class)); - $previous = $exception->getPrevious(); - while ($previous) { - $this->line(sprintf('Caused by: %s - %s', $previous::class, $previous->getMessage())); - $previous = $previous->getPrevious(); - } + if ($this->isDebug()) { + $this->renderDebugException($exception); } return self::FAILURE; } finally { $ownershipReport = $this->ownershipService->repairStandardPaths($extensionId); - if ($ownershipReport !== [] && ((bool) $this->option('debug') || $this->ownershipService->isRunningAsRoot())) { - $this->components->info(sprintf( - 'Repaired ownership for extension paths using %s:%s (%s).', - $ownershipReport['user'], - $ownershipReport['group'], - $ownershipReport['sourcePath'] - )); + if ($ownershipReport !== [] && ($this->isDebug() || $this->ownershipService->isRunningAsRoot())) { + $this->renderOwnershipReport($ownershipReport); } } @@ -63,4 +57,4 @@ public function handle(): int return self::SUCCESS; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/Extensions/UpdateExtensionCommand.php b/app/Console/Commands/Extensions/UpdateExtensionCommand.php index 12579cf10..3ec7d2a8d 100644 --- a/app/Console/Commands/Extensions/UpdateExtensionCommand.php +++ b/app/Console/Commands/Extensions/UpdateExtensionCommand.php @@ -2,18 +2,16 @@ namespace Everest\Console\Commands\Extensions; +use Everest\Console\Commands\Extensions\Concerns\HandlesExtensionPackages; use Everest\Console\Commands\Extensions\Concerns\InteractsWithExtensionRepositories; -use Everest\Exceptions\DisplayException; use Everest\Models\ExtensionRepository; use Everest\Services\Extensions\ExtensionFilesystemOwnershipService; -use Everest\Services\Extensions\ExtensionPackageArtifactService; use Everest\Services\Extensions\ExtensionPackageUpdateService; use Illuminate\Console\Command; -use Illuminate\Support\Str; class UpdateExtensionCommand extends Command { - use InteractsWithExtensionRepositories; + use HandlesExtensionPackages, InteractsWithExtensionRepositories; protected $signature = 'p:extensions:update {source? : Extension id from a configured repository, or a local package file path} @@ -29,7 +27,6 @@ class UpdateExtensionCommand extends Command public function __construct( private ExtensionPackageUpdateService $updateService, - private ExtensionPackageArtifactService $artifactService, private ExtensionFilesystemOwnershipService $ownershipService ) { parent::__construct(); @@ -41,7 +38,7 @@ public function handle(): int $resolution = ['extensionId' => null]; try { - $resolution = $this->resolveUpdateResolution($source); + $resolution = $this->resolveResolution($source, 'update'); if ($this->isDebug()) { $this->renderDebugResolution($resolution); @@ -87,272 +84,4 @@ public function handle(): int return self::SUCCESS; } - - /** - * @return array - */ - protected function resolveUpdateResolution(string $source): array - { - $cwd = getcwd() ?: base_path(); - $discoveredArtifacts = $this->artifactService->discoverArchives($cwd); - $validArtifacts = array_values(array_filter($discoveredArtifacts, fn (array $artifact): bool => !isset($artifact['error']))); - $explicitPath = $this->option('path') ? trim((string) $this->option('path')) : null; - - if ($explicitPath !== null && $explicitPath !== '') { - return $this->createFileResolution($explicitPath, $cwd, $discoveredArtifacts, $source); - } - - if ($this->option('file')) { - return $this->resolveFileModeSelection($source, $cwd, $discoveredArtifacts, $validArtifacts); - } - - if ($source !== '' && $this->artifactService->looksLikeArchiveReference($source, $cwd)) { - return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); - } - - if ($source !== '') { - $matchingLocal = array_values(array_filter( - $validArtifacts, - fn (array $artifact): bool => ($artifact['extensionId'] ?? null) === $source - )); - - if (count($matchingLocal) === 1) { - if ($this->option('yes') || $this->confirm(sprintf( - 'Found local package %s (%s) in %s. Update from that file instead of using the repository?', - $matchingLocal[0]['name'], - $matchingLocal[0]['version'], - $cwd, - ), true)) { - return $this->createFileResolution($matchingLocal[0]['archivePath'], $cwd, $discoveredArtifacts, $source); - } - } elseif ($validArtifacts !== [] && !$this->option('yes')) { - $choice = $this->choice( - sprintf('Found %d local extension package file(s) in %s while you asked for "%s". What do you want to do?', count($validArtifacts), $cwd, $source), - ['Use repository update', 'Select a discovered package', 'Enter a path', 'Cancel'], - 'Use repository update' - ); - - if ($choice === 'Select a discovered package') { - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, $source); - } - - if ($choice === 'Enter a path') { - $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); - - return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $source); - } - - if ($choice === 'Cancel') { - throw new DisplayException('Update cancelled.'); - } - } - } - - if ($source === '') { - if ($validArtifacts === []) { - throw new DisplayException('No extension id or local package file was provided. Run this command in a directory containing a .M12LabsExtension file, pass --path, or provide an extension id.'); - } - - if (count($validArtifacts) === 1) { - if ($this->option('yes') || $this->confirm(sprintf( - 'Update from the local package %s (%s) found in %s?', - $validArtifacts[0]['name'], - $validArtifacts[0]['version'], - $cwd, - ), true)) { - return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); - } - - throw new DisplayException('Update cancelled.'); - } - - if ($this->option('yes')) { - throw new DisplayException('Multiple local extension packages were found. Re-run without --yes to select one, or pass --path explicitly.'); - } - - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null); - } - - $repository = $this->resolveRepository($this->option('repository')); - - return [ - 'mode' => 'repository', - 'extensionId' => $source, - 'repository' => $repository, - 'release' => $this->option('release') ? trim((string) $this->option('release')) : null, - 'discoveredArtifacts' => $discoveredArtifacts, - 'cwd' => $cwd, - ]; - } - - /** - * @param array> $discoveredArtifacts - * @param array> $validArtifacts - * @return array - */ - private function resolveFileModeSelection(string $source, string $cwd, array $discoveredArtifacts, array $validArtifacts): array - { - if ($source !== '') { - return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); - } - - if ($validArtifacts === []) { - throw new DisplayException('No local .M12LabsExtension files were found in the current directory. Pass a path or omit --file to update from a repository.'); - } - - if (count($validArtifacts) === 1) { - return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); - } - - if ($this->option('yes')) { - throw new DisplayException('Multiple local extension packages were found. Pass --path or re-run without --yes to choose one interactively.'); - } - - return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null); - } - - /** - * @param array> $validArtifacts - * @param array> $discoveredArtifacts - * @return array - */ - private function selectDiscoveredArtifact(array $validArtifacts, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array - { - $choices = []; - foreach ($validArtifacts as $index => $artifact) { - $choices[] = sprintf('%d. %s (%s) [%s]', $index + 1, $artifact['name'], $artifact['version'], $artifact['archiveName']); - } - - $choices[] = 'Enter a path'; - $choices[] = 'Cancel'; - - $choice = $this->choice( - sprintf('Found %d local extension package file(s) in %s. Select one to update from.', count($validArtifacts), $cwd), - $choices, - $choices[0] - ); - - if ($choice === 'Enter a path') { - $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); - - return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $requestedSource); - } - - if ($choice === 'Cancel') { - throw new DisplayException('Update cancelled.'); - } - - $selectedIndex = max(0, ((int) Str::before($choice, '.')) - 1); - $selectedArtifact = $validArtifacts[$selectedIndex] ?? null; - if (!$selectedArtifact) { - throw new DisplayException('Unable to resolve the selected local package.'); - } - - return $this->createFileResolution($selectedArtifact['archivePath'], $cwd, $discoveredArtifacts, $requestedSource ?: $selectedArtifact['extensionId']); - } - - /** - * @param array> $discoveredArtifacts - * @return array - */ - private function createFileResolution(string $path, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array - { - $artifact = $this->artifactService->inspectArchive($path, $cwd); - - return [ - 'mode' => 'file', - 'extensionId' => $artifact['extensionId'], - 'archivePath' => $artifact['archivePath'], - 'label' => $this->option('label') ? trim((string) $this->option('label')) : sprintf('Manual package file (%s)', $artifact['archiveName']), - 'artifact' => $artifact, - 'requestedSource' => $requestedSource, - 'discoveredArtifacts' => $discoveredArtifacts, - 'cwd' => $cwd, - ]; - } - - /** - * @param array $resolution - */ - private function renderDebugResolution(array $resolution): void - { - $this->newLine(); - $this->components->twoColumnDetail('Mode', (string) $resolution['mode']); - $this->components->twoColumnDetail('Working directory', (string) ($resolution['cwd'] ?? base_path())); - - if (!empty($resolution['discoveredArtifacts'])) { - $rows = []; - foreach ($resolution['discoveredArtifacts'] as $artifact) { - $rows[] = [ - $artifact['extensionId'] ?? 'invalid', - $artifact['version'] ?? '-', - $artifact['archiveName'] ?? basename((string) ($artifact['archivePath'] ?? 'unknown')), - $artifact['error'] ?? 'ok', - ]; - } - - $this->table(['Extension', 'Version', 'Artifact', 'Status'], $rows); - } - - if ($resolution['mode'] === 'file' && isset($resolution['artifact'])) { - $artifact = $resolution['artifact']; - $this->table(['Field', 'Value'], [ - ['Extension', $artifact['extensionId']], - ['Version', $artifact['version']], - ['Name', $artifact['name']], - ['Archive', $artifact['archivePath']], - ['Files', (string) $artifact['fileCount']], - ]); - - return; - } - - /** @var ExtensionRepository $repository */ - $repository = $resolution['repository']; - $this->table(['Field', 'Value'], [ - ['Extension', $resolution['extensionId']], - ['Repository', $repository->name], - ['Repository slug', $repository->slug], - ['Release', $resolution['release'] ?? 'latest'], - ]); - } - - private function renderDebugException(\Throwable $exception): void - { - $this->newLine(); - $this->line(sprintf('Debug: %s', $exception::class)); - - $previous = $exception->getPrevious(); - while ($previous) { - $this->line(sprintf('Caused by: %s - %s', $previous::class, $previous->getMessage())); - $previous = $previous->getPrevious(); - } - } - - /** - * @param array $report - */ - private function renderOwnershipReport(array $report): void - { - if ($report === [] || empty($report['paths'])) { - return; - } - - $this->newLine(); - $this->components->info(sprintf( - 'Repaired ownership for extension paths using %s:%s (%s).', - $report['user'], - $report['group'], - $report['sourcePath'] - )); - - if ($this->isDebug()) { - $this->table(['Path'], array_map(fn (string $path) => [$path], $report['paths'])); - } - } - - protected function isDebug(): bool - { - return (bool) $this->option('debug'); - } } diff --git a/app/Services/Extensions/ExtensionInstallProgressService.php b/app/Services/Extensions/ExtensionInstallProgressService.php index dc8d24147..acda027c4 100644 --- a/app/Services/Extensions/ExtensionInstallProgressService.php +++ b/app/Services/Extensions/ExtensionInstallProgressService.php @@ -64,12 +64,13 @@ class ExtensionInstallProgressService public function report(string $action, string $extensionId, string $stage, ?int $batchTotal = null, ?int $batchCurrent = null): void { $validStages = match ($action) { - 'install' => self::INSTALL_STAGES, - 'update' => self::UPDATE_STAGES, - 'batch-install' => self::INSTALL_STAGES, + 'install' => self::INSTALL_STAGES, + 'uninstall' => self::UNINSTALL_STAGES, + 'update' => self::UPDATE_STAGES, + 'batch-install' => self::INSTALL_STAGES, 'batch-uninstall' => self::UNINSTALL_STAGES, - 'batch-update' => self::UPDATE_STAGES, - default => self::UNINSTALL_STAGES, + 'batch-update' => self::UPDATE_STAGES, + default => throw new \InvalidArgumentException(sprintf('Unknown extension action "%s".', $action)), }; if (!in_array($stage, $validStages, true)) { diff --git a/app/Services/Extensions/ExtensionOperationLockService.php b/app/Services/Extensions/ExtensionOperationLockService.php index 0eae764eb..f24ed2307 100644 --- a/app/Services/Extensions/ExtensionOperationLockService.php +++ b/app/Services/Extensions/ExtensionOperationLockService.php @@ -36,28 +36,21 @@ public function withinLock(string $action, ?string $subject, callable $callback) private function buildBlockedMessage(): string { $context = Cache::get(self::CONTEXT_KEY); + $action = is_array($context) ? ($context['action'] ?? null) : null; $subject = is_array($context) ? trim((string) ($context['subject'] ?? '')) : ''; - return match (is_array($context) ? ($context['action'] ?? null) : null) { - 'install' => $subject !== '' - ? sprintf( - 'Another extension is currently being installed (%s). Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - $subject - ) - : 'Another extension is currently being installed. Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - 'update' => $subject !== '' - ? sprintf( - 'Another extension is currently being updated (%s). Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - $subject - ) - : 'Another extension is currently being updated. Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - 'uninstall' => $subject !== '' - ? sprintf( - 'Another extension is currently being uninstalled (%s). Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - $subject - ) - : 'Another extension is currently being uninstalled. Wait for the previous extension action to finish before starting a new install, update, or uninstall.', - default => 'Another extension action is already running. Wait for the previous install, update, or uninstall to finish before starting a new one.', - }; + if ($action === null) { + return 'Another extension action is already running. Wait for the previous install, update, or uninstall to finish before starting a new one.'; + } + + // NOTE: Current actions (install, update, uninstall) all form regular past participles + // with "-ed". If a new action with an irregular past tense is added, use a lookup map. + $suffix = $subject !== '' ? sprintf(' (%s)', $subject) : ''; + + return sprintf( + 'Another extension is currently being %sed%s. Wait for the previous extension action to finish before starting a new install, update, or uninstall.', + $action, + $suffix + ); } } \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionPackageArtifactService.php b/app/Services/Extensions/ExtensionPackageArtifactService.php index 34d072394..8ed212a6f 100644 --- a/app/Services/Extensions/ExtensionPackageArtifactService.php +++ b/app/Services/Extensions/ExtensionPackageArtifactService.php @@ -4,12 +4,15 @@ use Everest\Exceptions\DisplayException; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use ZipArchive; class ExtensionPackageArtifactService { - private const MANIFEST_FILENAME = 'm12labs-extension.json'; + public const MANIFEST_FILENAME = 'm12labs-extension.json'; + public const PACKAGE_ARTIFACT_FILENAME = 'package.M12LabsExtension'; /** * @return array @@ -157,6 +160,139 @@ public function resolveArchivePath(string $archivePath, ?string $workingDirector throw new DisplayException(sprintf('The extension package file "%s" was not found.', $archivePath)); } + // --------------------------------------------------------------------------- + // Shared archive helpers — used by install and update services + // --------------------------------------------------------------------------- + + public function downloadArchive(string $location, string $destination): void + { + if (Str::startsWith($location, ['http://', 'https://'])) { + $response = Http::timeout(120)->withOptions(['sink' => $destination])->get($location); + if (!$response->successful()) { + throw new DisplayException(sprintf('Unable to download extension archive from "%s".', $location)); + } + + return; + } + + $sourcePath = Str::startsWith($location, 'file://') ? rawurldecode(substr($location, 7)) : $location; + if (!is_file($sourcePath)) { + throw new DisplayException(sprintf('Extension archive "%s" was not found.', $sourcePath)); + } + + File::copy($sourcePath, $destination); + } + + public function verifyChecksum(string $path, string $expectedChecksum, string $label): void + { + if (hash_file('sha256', $path) !== $expectedChecksum) { + throw new DisplayException(sprintf('The %s checksum did not match the manifest.', $label)); + } + } + + public function extractArchive(string $archivePath, string $extractPath): void + { + $zip = new ZipArchive(); + if ($zip->open($archivePath) !== true) { + throw new DisplayException('The downloaded extension archive could not be opened.'); + } + + if (!$zip->extractTo($extractPath)) { + $zip->close(); + + throw new DisplayException('The downloaded extension archive could not be extracted.'); + } + + $zip->close(); + } + + /** + * @return array + */ + public function readPackageManifest(string $extractPath): array + { + $manifestPath = $extractPath . '/' . self::MANIFEST_FILENAME; + if (!is_file($manifestPath)) { + throw new DisplayException('The extension archive did not include an m12labs-extension.json manifest.'); + } + + $manifest = json_decode(File::get($manifestPath), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($manifest)) { + throw new DisplayException('The extension package manifest is invalid.'); + } + + return $manifest; + } + + /** + * Validate the manifest's extension id / version against expected values and return it unchanged. + * + * @param array $manifest + * @return array + */ + public function normalizeManifest(array $manifest, ?string $expectedExtensionId = null, ?string $expectedVersion = null): array + { + $extensionId = trim((string) Arr::get($manifest, 'extension.id', '')); + $version = trim((string) Arr::get($manifest, 'package.version', '')); + + if ($extensionId === '' || $version === '') { + throw new DisplayException('The extension package manifest is missing required metadata.'); + } + + if ($expectedExtensionId !== null && $extensionId !== $expectedExtensionId) { + throw new DisplayException('The downloaded package does not match the requested extension id.'); + } + + if ($expectedVersion !== null && $version !== $expectedVersion) { + throw new DisplayException('The downloaded package version does not match the repository manifest.'); + } + + return $manifest; + } + + /** + * @param array $versions + */ + public function assertCompatiblePanelVersions(array $versions): void + { + $versions = array_values(array_filter($versions, 'is_string')); + if ($versions === []) { + return; + } + + $currentVersion = (string) config('app.version'); + if (!in_array($currentVersion, $versions, true)) { + throw new DisplayException(sprintf( + 'This extension package supports M12Labs panel versions %s. The current panel version is %s.', + implode(', ', $versions), + $currentVersion + )); + } + } + + public function normalizeTargetPath(string $path, string $extensionId): string + { + $normalized = str_replace('\\', '/', trim($path)); + $normalized = trim($normalized, '/'); + + if ($normalized === '' || Str::contains($normalized, ['../', '..\\']) || Str::startsWith($normalized, '/')) { + throw new DisplayException('The extension package includes an unsafe target path.'); + } + + $allowedPrefixes = [ + sprintf('app/Extensions/Packages/%s/', $extensionId), + sprintf('resources/scripts/extensions/packages/%s/', $extensionId), + ]; + + foreach ($allowedPrefixes as $prefix) { + if (Str::startsWith($normalized, $prefix)) { + return $normalized; + } + } + + throw new DisplayException(sprintf('The package target path "%s" is not allowed by M12Labs.', $normalized)); + } + private function isSupportedArchiveName(string $path): bool { return Str::endsWith(Str::lower($path), ['.m12labsextension', '.zip']); diff --git a/app/Services/Extensions/ExtensionPackageBatchService.php b/app/Services/Extensions/ExtensionPackageBatchService.php index 180029f27..965f0a4de 100644 --- a/app/Services/Extensions/ExtensionPackageBatchService.php +++ b/app/Services/Extensions/ExtensionPackageBatchService.php @@ -25,6 +25,7 @@ public function __construct( private ExtensionPackageUpdateService $updateService, private ExtensionPanelRebuildService $rebuildService, private ExtensionOperationLockService $operationLockService, + private ExtensionFilesystemOwnershipService $ownershipService, private ExtensionInstallProgressService $progressService ) { } @@ -98,6 +99,7 @@ function (int $cmdIndex) use ($lastExtensionId, $total): void { } finally { $this->progressService->clear(); foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); $this->installService->cleanupPreparedInstall($prepared); } } @@ -165,6 +167,7 @@ function (int $cmdIndex) use ($lastExtensionId, $total): void { } finally { $this->progressService->clear(); foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); $this->uninstallService->cleanupPreparedUninstall($prepared); } } @@ -240,6 +243,7 @@ function (int $cmdIndex) use ($lastExtensionId, $total): void { } finally { $this->progressService->clear(); foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); $this->updateService->cleanupPreparedUpdate($prepared); } } diff --git a/app/Services/Extensions/ExtensionPackageFileService.php b/app/Services/Extensions/ExtensionPackageFileService.php new file mode 100644 index 000000000..7929164b8 --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageFileService.php @@ -0,0 +1,97 @@ + $files + * @param string $verb Human-readable operation verb for the error message (e.g. 'uninstalled', 'updated'). + */ + public function assertFilesUnmodified(array $files, string $verb): void + { + $modified = []; + + foreach ($files as $file) { + $targetPath = base_path($file->path); + if (!is_file($targetPath)) { + $modified[] = $file->path; + continue; + } + + if (hash_file('sha256', $targetPath) !== $file->installed_checksum) { + $modified[] = $file->path; + } + } + + if ($modified === []) { + return; + } + + $preview = implode(', ', array_slice($modified, 0, 5)); + $suffix = count($modified) > 5 ? ', and more' : ''; + + throw new DisplayException(sprintf( + 'The extension cannot be %s because these files were modified after installation: %s%s.', + $verb, + $preview, + $suffix + )); + } + + /** + * Copy all currently-present tracked files into $rollbackRoot, preserving relative paths. + * + * @param array $files + */ + public function createRollbackSnapshot(array $files, string $rollbackRoot): void + { + foreach ($files as $file) { + $targetPath = base_path($file->path); + if (!is_file($targetPath)) { + continue; + } + + $rollbackPath = $rollbackRoot . '/' . $file->path; + File::ensureDirectoryExists(dirname($rollbackPath)); + File::copy($targetPath, $rollbackPath); + } + } + + /** + * Restore tracked files from a snapshot directory created by createRollbackSnapshot(). + * + * @param array $files + */ + public function restoreRollbackSnapshot(array $files, string $rollbackRoot): void + { + foreach ($files as $file) { + $rollbackPath = $rollbackRoot . '/' . $file->path; + if (!is_file($rollbackPath)) { + continue; + } + + $targetPath = base_path($file->path); + $this->ownershipService->ensureWritablePath($targetPath, $file->path); + File::ensureDirectoryExists(dirname($targetPath)); + File::copy($rollbackPath, $targetPath); + } + } +} diff --git a/app/Services/Extensions/ExtensionPackageInstallService.php b/app/Services/Extensions/ExtensionPackageInstallService.php index cde793818..9f773b287 100644 --- a/app/Services/Extensions/ExtensionPackageInstallService.php +++ b/app/Services/Extensions/ExtensionPackageInstallService.php @@ -9,21 +9,17 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use ZipArchive; class ExtensionPackageInstallService { - private const MANIFEST_FILENAME = 'm12labs-extension.json'; - public const PACKAGE_ARTIFACT_FILENAME = 'package.M12LabsExtension'; - public function __construct( private ExtensionCatalogService $catalogService, private ExtensionPanelRebuildService $rebuildService, private ExtensionOperationLockService $operationLockService, private ExtensionFilesystemOwnershipService $ownershipService, - private ExtensionInstallProgressService $progressService + private ExtensionInstallProgressService $progressService, + private ExtensionPackageArtifactService $artifactService ) { } @@ -72,7 +68,7 @@ function (int $index) use ($prepared): void { public function installFromArchive(string $archivePath, ?string $sourceLabel = null): ExtensionPackage { - $resolvedArchivePath = $this->resolveLocalArchivePath($archivePath); + $resolvedArchivePath = $this->artifactService->resolveArchivePath($archivePath); $this->assertSupportedArchiveArtifact($resolvedArchivePath); return $this->operationLockService->withinLock('install', basename($resolvedArchivePath), function () use ($resolvedArchivePath, $sourceLabel) { @@ -223,7 +219,7 @@ private function performInstallFileOps( array $fallbackPackageMetadata ): array { $tempRoot = storage_path('app/extensions/tmp/' . Str::uuid()->toString()); - $archivePath = $tempRoot . '/' . self::PACKAGE_ARTIFACT_FILENAME; + $archivePath = $tempRoot . '/' . ExtensionPackageArtifactService::PACKAGE_ARTIFACT_FILENAME; $extractPath = $tempRoot . '/extract'; $appliedFiles = []; $resolvedExtensionId = $expectedExtensionId; @@ -233,25 +229,25 @@ private function performInstallFileOps( try { $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'downloading'); - $this->downloadArchive($archiveLocation, $archivePath); + $this->artifactService->downloadArchive($archiveLocation, $archivePath); $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'extracting'); $archiveChecksum = hash_file('sha256', $archivePath); if ($expectedArchiveChecksum !== null) { - $this->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); + $this->artifactService->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); } - $this->extractArchive($archivePath, $extractPath); + $this->artifactService->extractArchive($archivePath, $extractPath); $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'validating'); - $manifest = $this->readPackageManifest($extractPath); - $normalizedManifest = $this->normalizeManifest($manifest, $expectedExtensionId, $expectedVersion); + $manifest = $this->artifactService->readPackageManifest($extractPath); + $normalizedManifest = $this->artifactService->normalizeManifest($manifest, $expectedExtensionId, $expectedVersion); $extensionId = (string) Arr::get($normalizedManifest, 'extension.id'); $resolvedExtensionId = $extensionId; $backupRoot = storage_path('app/extensions/backups/' . $extensionId . '/' . Str::uuid()->toString()); $this->assertExtensionNotInstalled($extensionId); - $this->assertCompatiblePanelVersions($compatiblePanelVersions); - $this->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); + $this->artifactService->assertCompatiblePanelVersions($compatiblePanelVersions); + $this->artifactService->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); $this->ownershipService->repairStandardPaths($extensionId); $filePlans = $this->prepareFilePlans($extractPath, $normalizedManifest, $backupRoot, $extensionId); @@ -348,91 +344,6 @@ private function persistInstalledPackage( return $packageModel; } - private function downloadArchive(string $location, string $destination): void - { - if (Str::startsWith($location, ['http://', 'https://'])) { - $response = Http::timeout(120)->withOptions(['sink' => $destination])->get($location); - if (!$response->successful()) { - throw new DisplayException(sprintf('Unable to download extension archive from "%s".', $location)); - } - - return; - } - - $sourcePath = Str::startsWith($location, 'file://') ? rawurldecode(substr($location, 7)) : $location; - if (!is_file($sourcePath)) { - throw new DisplayException(sprintf('Extension archive "%s" was not found.', $sourcePath)); - } - - File::copy($sourcePath, $destination); - } - - private function verifyChecksum(string $path, string $expectedChecksum, string $label): void - { - $actualChecksum = hash_file('sha256', $path); - if ($actualChecksum !== $expectedChecksum) { - throw new DisplayException(sprintf('The %s checksum did not match the manifest.', $label)); - } - } - - private function extractArchive(string $archivePath, string $extractPath): void - { - $zip = new ZipArchive(); - if ($zip->open($archivePath) !== true) { - throw new DisplayException('The downloaded extension archive could not be opened.'); - } - - if (!$zip->extractTo($extractPath)) { - $zip->close(); - - throw new DisplayException('The downloaded extension archive could not be extracted.'); - } - - $zip->close(); - } - - /** - * @return array - */ - private function readPackageManifest(string $extractPath): array - { - $manifestPath = $extractPath . '/' . self::MANIFEST_FILENAME; - if (!is_file($manifestPath)) { - throw new DisplayException('The extension archive did not include an m12labs-extension.json manifest.'); - } - - $manifest = json_decode(File::get($manifestPath), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($manifest)) { - throw new DisplayException('The extension package manifest is invalid.'); - } - - return $manifest; - } - - /** - * @param array $manifest - * @return array - */ - private function normalizeManifest(array $manifest, ?string $expectedExtensionId = null, ?string $expectedVersion = null): array - { - $extensionId = trim((string) Arr::get($manifest, 'extension.id', '')); - $version = trim((string) Arr::get($manifest, 'package.version', '')); - - if ($extensionId === '' || $version === '') { - throw new DisplayException('The extension package manifest is missing required metadata.'); - } - - if ($expectedExtensionId !== null && $extensionId !== $expectedExtensionId) { - throw new DisplayException('The downloaded package does not match the requested extension id.'); - } - - if ($expectedVersion !== null && $version !== $expectedVersion) { - throw new DisplayException('The downloaded package version does not match the repository manifest.'); - } - - return $manifest; - } - private function assertExtensionNotInstalled(string $extensionId): void { if (ExtensionPackage::query()->where('extension_id', $extensionId)->exists()) { @@ -440,40 +351,11 @@ private function assertExtensionNotInstalled(string $extensionId): void } } - private function resolveLocalArchivePath(string $archivePath): string - { - $archivePath = trim($archivePath); - if ($archivePath === '') { - throw new DisplayException('Provide a path to a local .M12LabsExtension package file.'); - } - - if (Str::startsWith($archivePath, 'file://')) { - $archivePath = rawurldecode(substr($archivePath, 7)); - } - - $candidates = [$archivePath]; - if (!Str::startsWith($archivePath, '/')) { - $candidates[] = base_path($archivePath); - } - - foreach ($candidates as $candidate) { - $resolved = realpath($candidate); - if ($resolved && is_file($resolved)) { - return $resolved; - } - } - - throw new DisplayException(sprintf('The extension package file "%s" was not found.', $archivePath)); - } - private function assertSupportedArchiveArtifact(string $archivePath): void { - $normalizedPath = Str::lower($archivePath); - if (Str::endsWith($normalizedPath, ['.m12labsextension', '.zip'])) { - return; + if (!Str::endsWith(Str::lower($archivePath), ['.m12labsextension', '.zip'])) { + throw new DisplayException('Manual installs expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); } - - throw new DisplayException('Manual installs expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); } /** @@ -493,7 +375,7 @@ private function prepareFilePlans(string $extractPath, array $manifest, string $ continue; } - $path = $this->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); + $path = $this->artifactService->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); $checksum = trim((string) ($file['sha256'] ?? '')); if ($path === '' || $checksum === '') { @@ -505,7 +387,7 @@ private function prepareFilePlans(string $extractPath, array $manifest, string $ throw new DisplayException(sprintf('The extension package is missing "%s".', $path)); } - $this->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); + $this->artifactService->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); if (ExtensionPackageFile::query()->where('path', $path)->exists()) { throw new DisplayException(sprintf('The path "%s" is already managed by another installed extension.', $path)); @@ -537,49 +419,6 @@ private function prepareFilePlans(string $extractPath, array $manifest, string $ return $plans; } - private function normalizeTargetPath(string $path, string $extensionId): string - { - $normalized = str_replace('\\', '/', trim($path)); - $normalized = trim($normalized, '/'); - - if ($normalized === '' || Str::contains($normalized, ['../', '..\\']) || Str::startsWith($normalized, '/')) { - throw new DisplayException('The extension package includes an unsafe target path.'); - } - - $allowedPrefixes = [ - sprintf('app/Extensions/Packages/%s/', $extensionId), - sprintf('resources/scripts/extensions/packages/%s/', $extensionId), - ]; - - foreach ($allowedPrefixes as $prefix) { - if (Str::startsWith($normalized, $prefix)) { - return $normalized; - } - } - - throw new DisplayException(sprintf('The package target path "%s" is not allowed by M12Labs.', $normalized)); - } - - /** - * @param array $versions - */ - private function assertCompatiblePanelVersions(array $versions): void - { - $versions = array_values(array_filter($versions, 'is_string')); - if ($versions === []) { - return; - } - - $currentVersion = (string) config('app.version'); - if (!in_array($currentVersion, $versions, true)) { - throw new DisplayException(sprintf( - 'This extension package supports M12Labs panel versions %s. The current panel version is %s.', - implode(', ', $versions), - $currentVersion - )); - } - } - /** * @param array> $filePlans */ @@ -617,4 +456,4 @@ private function attemptRollbackRebuild(string $extensionId, string $reason): vo report($exception); } } -} \ No newline at end of file +} diff --git a/app/Services/Extensions/ExtensionPackageUninstallService.php b/app/Services/Extensions/ExtensionPackageUninstallService.php index fe1e53b93..689e2a062 100644 --- a/app/Services/Extensions/ExtensionPackageUninstallService.php +++ b/app/Services/Extensions/ExtensionPackageUninstallService.php @@ -16,9 +16,9 @@ public function __construct( private ExtensionPanelRebuildService $rebuildService, private ExtensionOperationLockService $operationLockService, private ExtensionFilesystemOwnershipService $ownershipService, - private ExtensionInstallProgressService $progressService - ) - { + private ExtensionInstallProgressService $progressService, + private ExtensionPackageFileService $fileService + ) { } public function uninstall(string $extensionId): void @@ -86,8 +86,8 @@ public function prepareUninstall(string $extensionId): array $this->ownershipService->repairStandardPaths($extensionId); $this->progressService->report('uninstall', $extensionId, 'validating'); - $this->assertFilesAreUnmodified($files->all()); - $this->createRollbackSnapshot($files->all(), $rollbackRoot); + $this->fileService->assertFilesUnmodified($files->all(), 'uninstalled'); + $this->fileService->createRollbackSnapshot($files->all(), $rollbackRoot); $this->assertWritableUninstallTargets($files->all()); try { @@ -118,7 +118,7 @@ public function prepareUninstall(string $extensionId): array 'rollbackRoot' => $rollbackRoot, ]; } catch (\Throwable $exception) { - $this->restoreRollbackSnapshot($files->all(), $rollbackRoot); + $this->fileService->restoreRollbackSnapshot($files->all(), $rollbackRoot); File::deleteDirectory($rollbackRoot); $this->ownershipService->repairStandardPaths($extensionId); @@ -162,7 +162,7 @@ public function finalizeUninstall(array $prepared): void */ public function rollbackUninstall(array $prepared): void { - $this->restoreRollbackSnapshot($prepared['files']->all(), $prepared['rollbackRoot']); + $this->fileService->restoreRollbackSnapshot($prepared['files']->all(), $prepared['rollbackRoot']); $this->ownershipService->repairStandardPaths($prepared['extensionId']); } @@ -178,75 +178,6 @@ public function cleanupPreparedUninstall(array $prepared): void } } - /** - * @param array $files - */ - private function assertFilesAreUnmodified(array $files): void - { - $modified = []; - - foreach ($files as $file) { - $targetPath = base_path($file->path); - if (!is_file($targetPath)) { - $modified[] = $file->path; - - continue; - } - - $currentChecksum = hash_file('sha256', $targetPath); - if ($currentChecksum !== $file->installed_checksum) { - $modified[] = $file->path; - } - } - - if ($modified === []) { - return; - } - - $preview = implode(', ', array_slice($modified, 0, 5)); - $suffix = count($modified) > 5 ? ', and more' : ''; - - throw new DisplayException( - sprintf('The extension cannot be uninstalled because these files were modified after installation: %s%s.', $preview, $suffix) - ); - } - - /** - * @param array $files - */ - private function createRollbackSnapshot(array $files, string $rollbackRoot): void - { - foreach ($files as $file) { - $targetPath = base_path($file->path); - if (!is_file($targetPath)) { - continue; - } - - $rollbackPath = $rollbackRoot . '/' . $file->path; - File::ensureDirectoryExists(dirname($rollbackPath)); - File::copy($targetPath, $rollbackPath); - } - } - - /** - * @param array $files - */ - private function restoreRollbackSnapshot(array $files, string $rollbackRoot): void - { - foreach ($files as $file) { - $rollbackPath = $rollbackRoot . '/' . $file->path; - $targetPath = base_path($file->path); - - if (!is_file($rollbackPath)) { - continue; - } - - $this->ownershipService->ensureWritablePath($targetPath, $file->path); - File::ensureDirectoryExists(dirname($targetPath)); - File::copy($rollbackPath, $targetPath); - } - } - /** * @param array $files */ @@ -273,4 +204,4 @@ private function attemptRollbackRebuild(string $extensionId, string $reason): vo report($exception); } } -} \ No newline at end of file +} diff --git a/app/Services/Extensions/ExtensionPackageUpdateService.php b/app/Services/Extensions/ExtensionPackageUpdateService.php index 4ec45b415..60b01bcf4 100644 --- a/app/Services/Extensions/ExtensionPackageUpdateService.php +++ b/app/Services/Extensions/ExtensionPackageUpdateService.php @@ -8,21 +8,18 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use ZipArchive; class ExtensionPackageUpdateService { - private const MANIFEST_FILENAME = 'm12labs-extension.json'; - public const PACKAGE_ARTIFACT_FILENAME = 'package.M12LabsExtension'; - public function __construct( private ExtensionCatalogService $catalogService, private ExtensionPanelRebuildService $rebuildService, private ExtensionOperationLockService $operationLockService, private ExtensionFilesystemOwnershipService $ownershipService, - private ExtensionInstallProgressService $progressService + private ExtensionInstallProgressService $progressService, + private ExtensionPackageArtifactService $artifactService, + private ExtensionPackageFileService $fileService ) { } @@ -65,8 +62,8 @@ function (int $index) use ($prepared): void { throw new DisplayException('Failed to update the selected extension package.', $exception); } finally { $this->progressService->clear(); - $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? $extensionId); if ($prepared !== null) { + $this->ownershipService->repairStandardPaths($prepared['extensionId']); $this->cleanupPreparedUpdate($prepared); } } @@ -78,7 +75,7 @@ function (int $index) use ($prepared): void { */ public function updateFromArchive(string $archivePath, ?string $sourceLabel = null): ExtensionPackage { - $resolvedPath = $this->resolveLocalArchivePath($archivePath); + $resolvedPath = $this->artifactService->resolveArchivePath($archivePath); $this->assertSupportedArchiveArtifact($resolvedPath); return $this->operationLockService->withinLock('update', basename($resolvedPath), function () use ($resolvedPath, $sourceLabel) { @@ -246,7 +243,7 @@ public function finalizeUpdate(array $prepared): ExtensionPackage public function rollbackUpdate(array $prepared): void { if ($prepared['existingPackage']) { - $this->restoreRollbackSnapshot($prepared['existingPackage']->files->all(), $prepared['rollbackRoot']); + $this->fileService->restoreRollbackSnapshot($prepared['existingPackage']->files->all(), $prepared['rollbackRoot']); } $this->ownershipService->repairStandardPaths($prepared['extensionId']); @@ -289,7 +286,7 @@ private function performUpdateFileOps( array $fallbackPackageMetadata ): array { $tempRoot = storage_path('app/extensions/tmp/' . Str::uuid()->toString()); - $archivePath = $tempRoot . '/' . self::PACKAGE_ARTIFACT_FILENAME; + $archivePath = $tempRoot . '/' . ExtensionPackageArtifactService::PACKAGE_ARTIFACT_FILENAME; $extractPath = $tempRoot . '/extract'; $rollbackRoot = storage_path('app/extensions/tmp-update/' . Str::uuid()->toString()); $resolvedExtensionId = $extensionId; @@ -301,18 +298,18 @@ private function performUpdateFileOps( try { $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'downloading'); - $this->downloadArchive($archiveLocation, $archivePath); + $this->artifactService->downloadArchive($archiveLocation, $archivePath); $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'extracting'); $archiveChecksum = hash_file('sha256', $archivePath); if ($expectedArchiveChecksum !== null) { - $this->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); + $this->artifactService->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); } - $this->extractArchive($archivePath, $extractPath); + $this->artifactService->extractArchive($archivePath, $extractPath); $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'validating'); - $manifest = $this->readPackageManifest($extractPath); - $normalizedManifest = $this->normalizeManifest($manifest, $extensionId, $expectedVersion); + $manifest = $this->artifactService->readPackageManifest($extractPath); + $normalizedManifest = $this->artifactService->normalizeManifest($manifest, $extensionId, $expectedVersion); $resolvedExtensionId = (string) Arr::get($normalizedManifest, 'extension.id'); $existingPackage = ExtensionPackage::query() @@ -324,12 +321,12 @@ private function performUpdateFileOps( throw new DisplayException('This extension is not currently installed. Use the install command to install it first.'); } - $this->assertCompatiblePanelVersions($compatiblePanelVersions); - $this->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); + $this->artifactService->assertCompatiblePanelVersions($compatiblePanelVersions); + $this->artifactService->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); $this->ownershipService->repairStandardPaths($resolvedExtensionId); - $this->assertInstalledFilesUnmodified($existingPackage->files->all()); - $this->createRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); + $this->fileService->assertFilesUnmodified($existingPackage->files->all(), 'updated'); + $this->fileService->createRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); $newBackupRoot = storage_path('app/extensions/backups/' . $resolvedExtensionId . '/' . Str::uuid()->toString()); @@ -371,23 +368,23 @@ private function performUpdateFileOps( } return [ - 'extensionId' => $resolvedExtensionId, - 'existingPackage' => $existingPackage, - 'normalizedManifest' => $normalizedManifest, + 'extensionId' => $resolvedExtensionId, + 'existingPackage' => $existingPackage, + 'normalizedManifest' => $normalizedManifest, 'fallbackPackageMetadata' => $fallbackPackageMetadata, - 'newFilePlans' => $newFilePlans, - 'oldOnlyFiles' => $oldOnlyFiles, - 'archiveChecksum' => is_string($archiveChecksum) ? $archiveChecksum : null, - 'sourceRepositoryId' => $sourceRepositoryId, - 'sourceRepositoryName' => $sourceRepositoryName, - 'sourceRegistryUrl' => $sourceRegistryUrl, - 'sourceArchiveUrl' => $sourceArchiveUrl, - 'rollbackRoot' => $rollbackRoot, - 'tempRoot' => $tempRoot, + 'newFilePlans' => $newFilePlans, + 'oldOnlyFiles' => $oldOnlyFiles, + 'archiveChecksum' => is_string($archiveChecksum) ? $archiveChecksum : null, + 'sourceRepositoryId' => $sourceRepositoryId, + 'sourceRepositoryName' => $sourceRepositoryName, + 'sourceRegistryUrl' => $sourceRegistryUrl, + 'sourceArchiveUrl' => $sourceArchiveUrl, + 'rollbackRoot' => $rollbackRoot, + 'tempRoot' => $tempRoot, ]; } catch (\Throwable $exception) { if ($existingPackage) { - $this->restoreRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); + $this->fileService->restoreRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); } $this->ownershipService->repairStandardPaths($resolvedExtensionId); @@ -430,7 +427,7 @@ private function prepareUpdateFilePlans( continue; } - $path = $this->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); + $path = $this->artifactService->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); $checksum = trim((string) ($file['sha256'] ?? '')); if ($path === '' || $checksum === '') { @@ -442,7 +439,7 @@ private function prepareUpdateFilePlans( throw new DisplayException(sprintf('The extension package is missing "%s".', $path)); } - $this->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); + $this->artifactService->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); // Ensure the path is not owned by a different extension. if (ExtensionPackageFile::query() @@ -482,12 +479,12 @@ private function prepareUpdateFilePlans( } $plans[] = [ - 'path' => $path, - 'sourcePath' => $sourcePath, - 'targetPath' => $targetPath, - 'operation' => $operation, - 'checksum' => $checksum, - 'backupPath' => $backupPath, + 'path' => $path, + 'sourcePath' => $sourcePath, + 'targetPath' => $targetPath, + 'operation' => $operation, + 'checksum' => $checksum, + 'backupPath' => $backupPath, 'backupChecksum' => $backupChecksum, ]; } @@ -495,79 +492,6 @@ private function prepareUpdateFilePlans( return $plans; } - /** - * Fail if any tracked file has been externally modified since it was installed. - * - * @param array $files - */ - private function assertInstalledFilesUnmodified(array $files): void - { - $modified = []; - - foreach ($files as $file) { - $targetPath = base_path($file->path); - if (!is_file($targetPath)) { - $modified[] = $file->path; - continue; - } - - if (hash_file('sha256', $targetPath) !== $file->installed_checksum) { - $modified[] = $file->path; - } - } - - if ($modified === []) { - return; - } - - $preview = implode(', ', array_slice($modified, 0, 5)); - $suffix = count($modified) > 5 ? ', and more' : ''; - - throw new DisplayException( - sprintf('The extension cannot be updated because these files were modified after installation: %s%s.', $preview, $suffix) - ); - } - - /** - * Snapshot all currently installed files to a temporary directory. - * - * @param array $files - */ - private function createRollbackSnapshot(array $files, string $rollbackRoot): void - { - foreach ($files as $file) { - $targetPath = base_path($file->path); - if (!is_file($targetPath)) { - continue; - } - - $rollbackPath = $rollbackRoot . '/' . $file->path; - File::ensureDirectoryExists(dirname($rollbackPath)); - File::copy($targetPath, $rollbackPath); - } - } - - /** - * Restore all tracked files to the state captured in the rollback snapshot. - * - * @param array $files - */ - private function restoreRollbackSnapshot(array $files, string $rollbackRoot): void - { - foreach ($files as $file) { - $rollbackPath = $rollbackRoot . '/' . $file->path; - $targetPath = base_path($file->path); - - if (!is_file($rollbackPath)) { - continue; - } - - $this->ownershipService->ensureWritablePath($targetPath, $file->path); - File::ensureDirectoryExists(dirname($targetPath)); - File::copy($rollbackPath, $targetPath); - } - } - /** * Assert that all target paths are writable before making any changes. * @@ -591,167 +515,11 @@ private function assertWritableUpdateTargets(array $newFilePlans, array $oldOnly } } - private function downloadArchive(string $location, string $destination): void - { - if (Str::startsWith($location, ['http://', 'https://'])) { - $response = Http::timeout(120)->withOptions(['sink' => $destination])->get($location); - if (!$response->successful()) { - throw new DisplayException(sprintf('Unable to download extension archive from "%s".', $location)); - } - - return; - } - - $sourcePath = Str::startsWith($location, 'file://') ? rawurldecode(substr($location, 7)) : $location; - if (!is_file($sourcePath)) { - throw new DisplayException(sprintf('Extension archive "%s" was not found.', $sourcePath)); - } - - File::copy($sourcePath, $destination); - } - - private function verifyChecksum(string $path, string $expectedChecksum, string $label): void - { - $actualChecksum = hash_file('sha256', $path); - if ($actualChecksum !== $expectedChecksum) { - throw new DisplayException(sprintf('The %s checksum did not match the manifest.', $label)); - } - } - - private function extractArchive(string $archivePath, string $extractPath): void - { - $zip = new ZipArchive(); - if ($zip->open($archivePath) !== true) { - throw new DisplayException('The downloaded extension archive could not be opened.'); - } - - if (!$zip->extractTo($extractPath)) { - $zip->close(); - throw new DisplayException('The downloaded extension archive could not be extracted.'); - } - - $zip->close(); - } - - /** - * @return array - */ - private function readPackageManifest(string $extractPath): array - { - $manifestPath = $extractPath . '/' . self::MANIFEST_FILENAME; - if (!is_file($manifestPath)) { - throw new DisplayException('The extension archive did not include an m12labs-extension.json manifest.'); - } - - $manifest = json_decode(File::get($manifestPath), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($manifest)) { - throw new DisplayException('The extension package manifest is invalid.'); - } - - return $manifest; - } - - /** - * @param array $manifest - * @return array - */ - private function normalizeManifest(array $manifest, ?string $expectedExtensionId = null, ?string $expectedVersion = null): array - { - $extensionId = trim((string) Arr::get($manifest, 'extension.id', '')); - $version = trim((string) Arr::get($manifest, 'package.version', '')); - - if ($extensionId === '' || $version === '') { - throw new DisplayException('The extension package manifest is missing required metadata.'); - } - - if ($expectedExtensionId !== null && $extensionId !== $expectedExtensionId) { - throw new DisplayException('The downloaded package does not match the requested extension id.'); - } - - if ($expectedVersion !== null && $version !== $expectedVersion) { - throw new DisplayException('The downloaded package version does not match the repository manifest.'); - } - - return $manifest; - } - - /** - * @param array $versions - */ - private function assertCompatiblePanelVersions(array $versions): void - { - $versions = array_values(array_filter($versions, 'is_string')); - if ($versions === []) { - return; - } - - $currentVersion = (string) config('app.version'); - if (!in_array($currentVersion, $versions, true)) { - throw new DisplayException(sprintf( - 'This extension package supports M12Labs panel versions %s. The current panel version is %s.', - implode(', ', $versions), - $currentVersion - )); - } - } - - private function normalizeTargetPath(string $path, string $extensionId): string - { - $normalized = str_replace('\\', '/', trim($path)); - $normalized = trim($normalized, '/'); - - if ($normalized === '' || Str::contains($normalized, ['../', '..\\']) || Str::startsWith($normalized, '/')) { - throw new DisplayException('The extension package includes an unsafe target path.'); - } - - $allowedPrefixes = [ - sprintf('app/Extensions/Packages/%s/', $extensionId), - sprintf('resources/scripts/extensions/packages/%s/', $extensionId), - ]; - - foreach ($allowedPrefixes as $prefix) { - if (Str::startsWith($normalized, $prefix)) { - return $normalized; - } - } - - throw new DisplayException(sprintf('The package target path "%s" is not allowed by M12Labs.', $normalized)); - } - - private function resolveLocalArchivePath(string $archivePath): string - { - $archivePath = trim($archivePath); - if ($archivePath === '') { - throw new DisplayException('Provide a path to a local .M12LabsExtension package file.'); - } - - if (Str::startsWith($archivePath, 'file://')) { - $archivePath = rawurldecode(substr($archivePath, 7)); - } - - $candidates = [$archivePath]; - if (!Str::startsWith($archivePath, '/')) { - $candidates[] = base_path($archivePath); - } - - foreach ($candidates as $candidate) { - $resolved = realpath($candidate); - if ($resolved && is_file($resolved)) { - return $resolved; - } - } - - throw new DisplayException(sprintf('The extension package file "%s" was not found.', $archivePath)); - } - private function assertSupportedArchiveArtifact(string $archivePath): void { - $normalizedPath = Str::lower($archivePath); - if (Str::endsWith($normalizedPath, ['.m12labsextension', '.zip'])) { - return; + if (!Str::endsWith(Str::lower($archivePath), ['.m12labsextension', '.zip'])) { + throw new DisplayException('Manual updates expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); } - - throw new DisplayException('Manual updates expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); } private function attemptRollbackRebuild(string $extensionId, string $reason): void