diff --git a/assets/modules/store/lang/az.php b/assets/modules/store/lang/az.php new file mode 100644 index 0000000000..f11592590b --- /dev/null +++ b/assets/modules/store/lang/az.php @@ -0,0 +1,11 @@ +composerArray['autoload']['psr-4'][$this->argument('key')] = $this->argument('value'); } - - + /** + * An autoload mapping changes no package, so rebuilding the autoloader is enough. + * + * Without this the inherited arguments fell back to a bare `update` of every + * dependency of the site, just to register one namespace. + * + * @return array + */ + public function buildComposerArguments(bool $minimalChanges = false): array + { + return ['command' => 'dump-autoload']; + } } diff --git a/core/src/Console/Packages/InstallPackageRequireCommand.php b/core/src/Console/Packages/InstallPackageRequireCommand.php index 53a7db4aff..87f6fb53b6 100644 --- a/core/src/Console/Packages/InstallPackageRequireCommand.php +++ b/core/src/Console/Packages/InstallPackageRequireCommand.php @@ -2,18 +2,27 @@ use Composer\Console\Application; +use EvolutionCMS\Traits\RunsComposerShell; +use ExecWithFallback\ExecWithFallback; use Illuminate\Console\Command; use \EvolutionCMS; use Symfony\Component\Console\Input\ArrayInput; class InstallPackageRequireCommand extends Command { + use RunsComposerShell; + + /** + * Composer release that introduced `update --minimal-changes`. + */ + public const MINIMAL_CHANGES_SINCE = '2.7.0'; + /** * The name and signature of the console command. * * @var string */ - protected $signature = 'package:installrequire {key} {value} {composer_run=1} {--no-dev : Skip installing packages listed in require-dev} {--optimize-autoloader : Optimize Composer autoload files after update}'; + protected $signature = 'package:installrequire {key} {value} {composer_run=1} {--no-dev : Skip installing packages listed in require-dev} {--optimize-autoloader : Optimize Composer autoload files after update} {--keep-dependencies : Leave installed dependencies as they are, so a package from a local artifact repository installs without network access}'; /** * The console command description. @@ -28,12 +37,24 @@ class InstallPackageRequireCommand extends Command */ protected $composer = EVO_CORE_PATH . 'custom/composer.json'; + /** + * Lock file Composer rewrites during the update. + * @var string + */ + protected $composerLock = EVO_CORE_PATH . 'composer.lock'; + /** * Packages touched by updateArray(); scopes the composer update to them. * @var array */ protected $affectedPackages = []; + /** + * Resolved by composerProcessCommand(); false until it has been asked. + * @var string|null|false + */ + protected $composerProcessCommand = false; + /** * @var array */ @@ -53,6 +74,7 @@ public function handle() { $composerExisted = file_exists($this->composer); $originalComposerContents = $composerExisted ? file_get_contents($this->composer) : null; + $originalLockContents = is_file($this->composerLock) ? file_get_contents($this->composerLock) : null; $this->checkFile(); if ($this->updateArray() === false) { @@ -60,12 +82,18 @@ public function handle() } $this->putComposer(); if ($this->argument('composer_run') == 1) { - $exitCode = $this->runComposer(); - if ((int) $exitCode !== 0) { + $exitCode = (int) $this->runComposer(); + if ($exitCode === 0 && !$this->applicationBoots()) { + $this->error('The site no longer boots after the Composer update.'); + $exitCode = self::FAILURE; + } + + if ($exitCode !== 0) { $this->restoreComposerState($composerExisted, $originalComposerContents); + $this->rollbackVendor($originalLockContents); } - return (int) $exitCode; + return $exitCode; } return self::SUCCESS; @@ -93,16 +121,44 @@ public function putComposer() /** * Run Composer update for the modified custom package requirements. * - * Optional flags are exposed for higher-level install flows that should behave like production - * updates. For example, `extras extras` can avoid dev dependencies and immediately build an - * optimized autoloader while direct `package:installrequire` calls keep their previous defaults. + * Composer runs as a separate process whenever PHP may start one. Run inside this process, + * it replaces the vendor files this very process is loading classes from, and a class loaded + * lazily halfway through the update (symfony/finder, say) is already gone: every remaining + * operation fails and the site is left without a working vendor directory. The in-process + * run is kept only for hosts that disable exec(), proc_open(), popen() and passthru() alike. * * @return int Composer process exit code. */ public function runComposer() { putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer'); - $input = new ArrayInput($this->buildComposerArguments()); + + $composerCommand = $this->composerProcessCommand(); + if ($composerCommand === null) { + $this->warn('No way to start a process is enabled; running Composer inside the current process.'); + + return $this->runComposerInProcess($this->buildComposerArguments( + $this->supportsMinimalChanges(\Composer\Composer::VERSION) + )); + } + + $arguments = $this->buildComposerArguments( + $this->supportsMinimalChanges($this->composerVersion($composerCommand)) + ); + + return $this->runComposerProcess($composerCommand, $arguments); + } + + /** + * Run Composer with the given arguments in the current process. + * + * @since 3.5.9 + * @param array $arguments + * @return int Composer exit code. + */ + protected function runComposerInProcess(array $arguments): int + { + $input = new ArrayInput($arguments); $application = new Application(); $application->setAutoExit(false); $originalCwd = function_exists('getcwd') ? getcwd() : false; @@ -118,7 +174,64 @@ public function runComposer() chdir($originalCwd); } } + } + /** + * Run Composer with the given arguments as a separate process. + * + * @since 3.5.9 + * @param string $composerCommand Composer command safe for shell usage. + * @param array $arguments + * @return int Composer exit code. + */ + protected function runComposerProcess(string $composerCommand, array $arguments): int + { + $output = []; + $exitCode = $this->execCoreShellCommand( + $composerCommand . ' ' . $this->buildComposerShellArguments($arguments), + $output + ); + + foreach ($output as $line) { + $this->line((string) $line); + } + + return $exitCode; + } + + /** + * Pick the Composer to start as a separate process. + * + * A standalone Composer comes first: it does not load a single file from the vendor + * directory it rewrites. The vendor copy is the last resort; as a separate process it + * still leaves this command alive to roll back when an update breaks Composer itself. + * + * @since 3.5.9 + * @return string|null Composer command safe for shell usage, or null when no process can be started. + */ + protected function composerProcessCommand(): ?string + { + if ($this->composerProcessCommand !== false) { + return $this->composerProcessCommand; + } + + $this->composerProcessCommand = null; + if (!ExecWithFallback::anyAvailable()) { + return null; + } + + $this->composerProcessCommand = $this->resolveComposerBinaryCommand(); + if ($this->composerProcessCommand !== null) { + return $this->composerProcessCommand; + } + + $vendorComposer = EVO_CORE_PATH . 'vendor/bin/composer'; + if (is_file($vendorComposer)) { + $this->warn('Standalone Composer not found; using the copy from the vendor directory.'); + $this->composerProcessCommand = $this->phpBinaryCommand() . ' ' . escapeshellarg($vendorComposer); + } + + return $this->composerProcessCommand; } /** @@ -128,28 +241,147 @@ public function runComposer() * like `composer require`/`remove` do. A bare `update` would also bump every * core dependency, including composer/composer running this very process. * + * @param bool $minimalChanges Whether the Composer that runs them knows `--minimal-changes`. * @return array */ - public function buildComposerArguments(): array + public function buildComposerArguments(bool $minimalChanges = false): array { $arguments = ['command' => 'update']; $packages = array_values(array_unique(array_filter(array_map('trim', $this->affectedPackages)))); if ($packages !== []) { $arguments['packages'] = $packages; - $arguments['--with-dependencies'] = true; + if (!$this->commandOptionEnabled('keep-dependencies')) { + $arguments['--with-dependencies'] = true; + } + // Dependencies shared with the rest of the site move only as far as the + // changed package needs them to, not to their newest release. + if ($minimalChanges) { + $arguments['--minimal-changes'] = true; + } } - if ($this->hasCommandOption('no-dev') && $this->option('no-dev')) { + if ($this->commandOptionEnabled('no-dev')) { $arguments['--no-dev'] = true; } - if ($this->hasCommandOption('optimize-autoloader') && $this->option('optimize-autoloader')) { + if ($this->commandOptionEnabled('optimize-autoloader')) { $arguments['--optimize-autoloader'] = true; } return $arguments; } + /** + * Turn the arguments of buildComposerArguments() into a shell command line. + * + * @since 3.5.9 + * @param array $arguments + * @return string + */ + public function buildComposerShellArguments(array $arguments): string + { + $parts = [escapeshellarg((string) ($arguments['command'] ?? 'update'))]; + + foreach ((array) ($arguments['packages'] ?? []) as $package) { + $parts[] = escapeshellarg((string) $package); + } + + foreach ($arguments as $key => $value) { + if (is_string($key) && str_starts_with($key, '--') && $value === true) { + $parts[] = $key; + } + } + + $parts[] = '--no-interaction'; + $parts[] = '--no-ansi'; + + return implode(' ', $parts); + } + + /** + * Whether a Composer version knows `update --minimal-changes`. + * + * @since 3.5.9 + * @param string|null $version Composer version, null when unknown. + * @return bool + */ + public function supportsMinimalChanges(?string $version): bool + { + return $version !== null && version_compare($version, self::MINIMAL_CHANGES_SINCE, '>='); + } + + /** + * Check that the site still boots after an update, in a fresh process. + * + * This process keeps the classes it loaded before the update, so only a new + * process can tell whether the rewritten vendor directory still works. Without + * a way to start one there is nothing to check with, and the update stands. + * + * @since 3.5.9 + * @return bool + */ + protected function applicationBoots(): bool + { + if (!ExecWithFallback::anyAvailable()) { + return true; + } + + $output = []; + $exitCode = $this->execCoreShellCommand($this->phpBinaryCommand() . ' artisan --version --no-ansi', $output); + if ($exitCode !== 0) { + foreach (array_slice($output, -8) as $line) { + $this->line((string) $line); + } + } + + return $exitCode === 0; + } + + /** + * Put vendor back the way the lock file described it before the update. + * + * Restoring custom/composer.json alone is not enough: Composer has already written + * the new lock file and replaced part of vendor. `composer install` from the old lock + * file downgrades what was updated and reinstalls what a failed step left half removed. + * + * @since 3.5.9 + * @param string|null $originalLockContents Lock file contents before the update, null when there was none. + * @return void + */ + protected function rollbackVendor(?string $originalLockContents): void + { + if ($originalLockContents === null) { + return; + } + + file_put_contents($this->composerLock, $originalLockContents); + $this->warn('Composer update failed; restoring the previous dependencies.'); + + $arguments = ['command' => 'install']; + if ($this->commandOptionEnabled('no-dev')) { + $arguments['--no-dev'] = true; + } + + $composerCommand = $this->composerProcessCommand(); + try { + $exitCode = $composerCommand === null + ? $this->runComposerInProcess($arguments) + : $this->runComposerProcess($composerCommand, $arguments); + } catch (\Throwable $exception) { + $exitCode = self::FAILURE; + $this->line($exception->getMessage()); + } + + if ($exitCode !== 0) { + $this->error('Restoring the previous dependencies failed. Run "composer install" in ' . EVO_CORE_PATH . ' manually.'); + } + } + + protected function commandOptionEnabled(string $name): bool + { + return $this->hasCommandOption($name) && (bool) $this->option($name); + } + protected function hasCommandOption(string $name): bool { return $this->getDefinition()->hasOption($name); diff --git a/core/src/Console/SiteUpdateCommand.php b/core/src/Console/SiteUpdateCommand.php index c79907ad5f..6477f9e6c0 100644 --- a/core/src/Console/SiteUpdateCommand.php +++ b/core/src/Console/SiteUpdateCommand.php @@ -6,7 +6,7 @@ use EvolutionCMS\Models\SystemSetting; use EvolutionCMS\Services\ComposerVersionSynchronizer; use EvolutionCMS\Services\Store\RemoteTransportService; -use ExecWithFallback\ExecWithFallback; +use EvolutionCMS\Traits\RunsComposerShell; use Illuminate\Console\Command; /** @@ -14,6 +14,8 @@ */ class SiteUpdateCommand extends Command { + use RunsComposerShell; + /** * Default GitHub repository used for core updates when no custom repository is configured. */ @@ -889,178 +891,6 @@ protected function composerDumpAutoloadCommand(): string return $this->composerBinaryCommand() . ' dump-autoload -o --no-dev --classmap-authoritative --no-scripts'; } - /** - * Resolve a Composer executable command for shell calls. - * - * Some shared-hosting environments expose Composer only as a shell alias such - * as ~/.composer/composer. PHP executes update commands through /bin/sh, where - * interactive bash aliases are not available, so we need a real executable path. - * - * @since 3.5.7 - * @return string Composer command safe for shell usage. - */ - protected function composerBinaryCommand(): string - { - foreach (['COMPOSER_BINARY', 'COMPOSER_BIN'] as $envName) { - $configured = trim((string) getenv($envName)); - if ($configured !== '') { - return escapeshellarg($configured); - } - } - - if ($this->shellCommandExists('composer')) { - return 'composer'; - } - - foreach ($this->composerBinaryCandidates() as $candidate) { - if ($this->isExecutableFile($candidate)) { - return escapeshellarg($candidate); - } - } - - return 'composer'; - } - - /** - * Check whether a path is something the shell can run. - * - * On Windows is_executable() answers false even for a genuine - * composer.bat — it does not consult PATHEXT the way the shell does — so - * every candidate would be rejected no matter which paths were offered. - * There the file existing is the only signal available. - * - * @since 3.5.8 - * @param string $path Absolute path to test. - * @return bool - */ - protected function isExecutableFile(string $path): bool - { - if (!is_file($path)) { - return false; - } - - return windows_os() ? true : is_executable($path); - } - - /** - * Build fallback Composer executable candidates. - * - * @since 3.5.7 - * @return array - */ - protected function composerBinaryCandidates(): array - { - $candidates = [ - '/usr/local/bin/composer', - '/usr/bin/composer', - ]; - - foreach ($this->homeDirectories() as $home) { - $candidates[] = $home . '/.composer/composer'; - } - - // Appended rather than switched on the platform. Every candidate is - // filtered by isExecutableFile() anyway, so an entry that cannot exist - // here costs one is_file() call, while a platform branch would be a - // new way to guess wrong — under WSL, or wherever the environment does - // not match what PHP_OS_FAMILY suggests. - $candidates = array_merge($candidates, $this->windowsComposerBinaryCandidates()); - - return array_values(array_unique($candidates)); - } - - /** - * Build fallback Composer executable candidates for Windows layouts. - * - * The POSIX list finds nothing here: there is no /usr/local/bin, and a - * per-user install puts a shim in %APPDATA%\Composer rather than in a - * ~/.composer/composer file. Only shell-runnable shims are listed — - * composer.phar is deliberately absent, because it needs `php` in front of - * it and this list feeds a command that is executed directly. - * - * @since 3.5.8 - * @return array - */ - protected function windowsComposerBinaryCandidates(): array - { - $candidates = []; - - // Where the Composer-Setup installer puts a machine-wide install. - $programData = trim((string) getenv('ProgramData')); - if ($programData !== '') { - $base = rtrim(str_replace('\\', '/', $programData), '/') . '/ComposerSetup/bin/composer'; - $candidates[] = $base . '.bat'; - $candidates[] = $base . '.exe'; - } - - // A per-user install. - $appData = trim((string) getenv('APPDATA')); - if ($appData !== '') { - $base = rtrim(str_replace('\\', '/', $appData), '/') . '/Composer/composer'; - $candidates[] = $base . '.bat'; - $candidates[] = $base . '.exe'; - } - - foreach ($this->homeDirectories() as $home) { - $candidates[] = $home . '/AppData/Roaming/Composer/composer.bat'; - } - - return array_values(array_unique(array_filter($candidates))); - } - - /** - * Resolve possible home directories without relying on shell "~" expansion. - * - * @since 3.5.7 - * @return array - */ - protected function homeDirectories(): array - { - $homes = []; - - foreach (['HOME', 'USERPROFILE'] as $envName) { - $home = trim((string) getenv($envName)); - if ($home !== '') { - $homes[] = rtrim(str_replace('\\', '/', $home), '/'); - } - } - - if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { - $user = posix_getpwuid(posix_getuid()); - if (is_array($user) && !empty($user['dir'])) { - $homes[] = rtrim(str_replace('\\', '/', (string) $user['dir']), '/'); - } - } - - return array_values(array_unique(array_filter($homes))); - } - - /** - * Check whether a command is available to the non-interactive shell. - * - * @since 3.5.7 - * @param string $command Command name. - * @return bool - */ - protected function shellCommandExists(string $command): bool - { - $output = []; - $exitCode = 1; - - // `command -v` is a POSIX shell builtin and /dev/null is a POSIX - // device; cmd.exe has neither, so on Windows this probe reported "not - // found" for every command — including ones plainly on PATH — and the - // resolver fell through to candidate paths that do not exist there - // either. `where` is the native equivalent and answers 0 when found. - $probe = windows_os() - ? 'where ' . escapeshellarg($command) . ' >NUL 2>NUL' - : 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1'; - - ExecWithFallback::exec($probe, $output, $exitCode); - - return (int) $exitCode === 0; - } - protected function normalizeUpdateRepository(string $repository): string { return trim($repository, " \t\n\r\0\x0B/"); @@ -1078,10 +908,9 @@ protected function normalizeUpdateRepository(string $repository): string */ protected function runCoreShellCommand(string $command): void { - $fullCommand = 'cd ' . escapeshellarg(EVO_CORE_PATH) . ' && ' . $command . ' 2>&1'; - ExecWithFallback::exec($fullCommand, $output, $exitCode); + $exitCode = $this->execCoreShellCommand($command, $output); - if ((int) $exitCode !== 0) { + if ($exitCode !== 0) { $message = 'Command failed: ' . $command; if (!empty($output)) { $message .= '. ' . implode("\n", array_slice($output, -8)); diff --git a/core/src/Services/Store/ComposerArtifactService.php b/core/src/Services/Store/ComposerArtifactService.php new file mode 100644 index 0000000000..1c9560e50f --- /dev/null +++ b/core/src/Services/Store/ComposerArtifactService.php @@ -0,0 +1,235 @@ +corePath = rtrim($corePath ?? EVO_CORE_PATH, '/\\') . '/'; + } + + /** + * Read the Composer package an archive holds. + * + * Composer finds composer.json at the root of an archive or inside its only top-level + * directory, as in a GitHub download; this looks in the same places. A legacy Extras + * package carries an install/ directory next to it and is left to the legacy installer. + * + * An archive shaped like a Composer package whose composer.json cannot be used is still + * reported, with "invalid" set: handed to the legacy installer instead, its files would + * be copied into the web root. + * + * @param string $zipPath Archive to inspect. + * @param string $fileName Name the archive was uploaded under; may carry the version. + * @return array{name: string, version: string, entry: string, composer: array, invalid: bool}|null + * Null when the archive holds no Composer package; version is '' when unknown. + */ + public function inspect(string $zipPath, string $fileName = ''): ?array + { + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) { + return null; + } + + try { + $entry = $this->findComposerJson($zip); + if ($entry === null) { + return null; + } + + $prefix = substr($entry, 0, -strlen('composer.json')); + if ($this->hasEntryUnder($zip, $prefix . 'install/')) { + return null; + } + + $composer = json_decode((string) $zip->getFromName($entry), true); + } finally { + $zip->close(); + } + + if (!is_array($composer) || !self::isPackageName((string) ($composer['name'] ?? ''))) { + return [ + 'name' => '', + 'version' => '', + 'entry' => $entry, + 'composer' => [], + 'invalid' => true, + ]; + } + + $version = trim((string) ($composer['version'] ?? '')); + if ($version === '') { + $version = (string) self::versionFromFileName($fileName); + } + + return [ + 'name' => (string) $composer['name'], + 'version' => $version, + 'entry' => $entry, + 'composer' => $composer, + 'invalid' => false, + ]; + } + + /** + * Keep an archive in the artifact repository and make Composer look there. + * + * Composer's artifact repository reads the version from the composer.json inside the + * archive and rejects a package without one, so a version known only from the file name + * is written into that composer.json. + * + * @param string $zipPath Uploaded archive. + * @param array $package Result of inspect() with a non-empty version. + * @return string Path of the stored archive. + */ + public function store(string $zipPath, array $package): string + { + $directory = $this->corePath . self::REPOSITORY_URL; + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new \RuntimeException('Unable to create ' . $directory . '.'); + } + + $target = $directory . '/' . self::archiveFileName($package['name'], $package['version']); + if (!copy($zipPath, $target)) { + throw new \RuntimeException('Unable to copy the archive to ' . $target . '.'); + } + + if (trim((string) ($package['composer']['version'] ?? '')) === '') { + $composer = $package['composer']; + $composer['version'] = $package['version']; + + $zip = new ZipArchive(); + if ($zip->open($target) !== true) { + @unlink($target); + throw new \RuntimeException('Unable to open ' . $target . '.'); + } + $zip->addFromString($package['entry'], (string) json_encode($composer, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $zip->close(); + } + + $this->registerRepository(); + + return $target; + } + + /** + * Declare the artifact repository in custom/composer.json once. + * + * @return void + */ + public function registerRepository(): void + { + $composerFile = $this->corePath . 'custom/composer.json'; + $composer = is_file($composerFile) ? json_decode((string) file_get_contents($composerFile), true) : null; + if (!is_array($composer)) { + $composer = [ + 'name' => 'evolutioncms/custom', + 'require' => [], + 'autoload' => [ + 'psr-4' => [], + ], + ]; + } + + $repositories = isset($composer['repositories']) && is_array($composer['repositories']) ? $composer['repositories'] : []; + foreach ($repositories as $repository) { + if (is_array($repository) + && ($repository['type'] ?? '') === 'artifact' + && trim((string) ($repository['url'] ?? ''), '/') === self::REPOSITORY_URL + ) { + return; + } + } + + $repositories[] = ['type' => 'artifact', 'url' => self::REPOSITORY_URL]; + $composer['repositories'] = $repositories; + + file_put_contents($composerFile, json_encode($composer, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + + /** + * File name of a stored archive: one file per package version. + */ + public static function archiveFileName(string $name, string $version): string + { + $safeVersion = preg_replace('~[^A-Za-z0-9._-]+~', '_', $version); + + return str_replace('/', '-', strtolower($name)) . '-' . $safeVersion . '.zip'; + } + + /** + * Version carried by an archive name such as "sgallery-1.5.2.zip" or "sgallery-v1.5.2.zip". + */ + public static function versionFromFileName(string $fileName): ?string + { + $pattern = '~(?:^|[-_ ])v?(\d+\.\d+(?:\.\d+){0,2}(?:-(?:alpha|beta|rc|patch)\.?\d*)?)\.zip$~i'; + if (preg_match($pattern, basename($fileName), $matches) !== 1) { + return null; + } + + return $matches[1]; + } + + /** + * Whether a string is a valid Composer package name. + */ + public static function isPackageName(string $name): bool + { + return preg_match('~^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$~', $name) === 1; + } + + private function findComposerJson(ZipArchive $zip): ?string + { + if ($zip->locateName('composer.json') !== false) { + return 'composer.json'; + } + + $topLevel = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = (string) $zip->getNameIndex($i); + $slash = strpos($name, '/'); + $topLevel[$slash === false ? $name : substr($name, 0, $slash + 1)] = true; + } + + if (count($topLevel) !== 1) { + return null; + } + + $directory = (string) array_key_first($topLevel); + if (!str_ends_with($directory, '/')) { + return null; + } + + return $zip->locateName($directory . 'composer.json') !== false ? $directory . 'composer.json' : null; + } + + private function hasEntryUnder(ZipArchive $zip, string $directory): bool + { + for ($i = 0; $i < $zip->numFiles; $i++) { + if (str_starts_with((string) $zip->getNameIndex($i), $directory)) { + return true; + } + } + + return false; + } +} diff --git a/core/src/Services/Store/ModuleActionService.php b/core/src/Services/Store/ModuleActionService.php index 6d7a4545f6..f6efb7131e 100644 --- a/core/src/Services/Store/ModuleActionService.php +++ b/core/src/Services/Store/ModuleActionService.php @@ -20,6 +20,17 @@ public function handle($store, $action, array $request = [], array $files = [], case 'install': case 'install_file': + if ($action === 'install_file') { + $artifactBody = $this->queueComposerArtifactInstall($store, $files); + if ($artifactBody !== null) { + return [ + 'handled' => true, + 'body' => $artifactBody, + 'terminate' => true, + ]; + } + } + $response = $store->packageInstallFlowService()->handleLegacyInstall($action, $request, $files, $get, $post); return [ 'handled' => true, @@ -154,6 +165,91 @@ protected function json(array $payload) ]; } + /** + * Queue the install of an uploaded archive that holds a Composer package. + * + * Such a package has no install/ directory for the legacy installer to run; it is + * kept in the local artifact repository and installed by the scheduler, exactly as + * a package picked from the catalog. + * + * @since 3.5.9 + * @return string|null Response text, or null when the upload is a legacy package. + */ + protected function queueComposerArtifactInstall($store, array $files) + { + $upload = $files['install_file'] ?? null; + $tmpName = is_array($upload) ? (string) ($upload['tmp_name'] ?? '') : ''; + $fileName = is_array($upload) ? (string) ($upload['name'] ?? '') : ''; + if ($tmpName === '' || !is_uploaded_file($tmpName) || strtolower(pathinfo($fileName, PATHINFO_EXTENSION)) !== 'zip') { + return null; + } + + return $this->queueComposerArtifactFile($store, new ComposerArtifactService(), $tmpName, $fileName); + } + + /** + * @since 3.5.9 + * @return string|null Response text, or null when the archive holds no Composer package. + */ + protected function queueComposerArtifactFile($store, ComposerArtifactService $artifacts, string $zipPath, string $fileName) + { + $package = $artifacts->inspect($zipPath, $fileName); + if ($package === null) { + return null; + } + + $lang = is_array($store->lang ?? null) ? $store->lang : []; + if ($package['invalid']) { + return e(sprintf( + $lang['install_file_artifact_invalid'] ?? 'The archive looks like a Composer package, but %1$s is not valid JSON with a package "name". Nothing was installed.', + $package['entry'] + )); + } + + if ($package['version'] === '') { + return e(sprintf( + $lang['install_file_artifact_no_version'] ?? 'The archive holds Composer package %1$s but no version. Add "version" to its composer.json or put the version in the file name, e.g. %2$s-1.0.0.zip.', + $package['name'], + basename($package['name']) + )); + } + + try { + $archive = $artifacts->store($zipPath, $package); + } catch (\Throwable $exception) { + return e(sprintf( + $lang['install_file_artifact_failed'] ?? 'Composer package %1$s could not be queued: %2$s', + $package['name'], + $exception->getMessage() + )); + } + + $response = $store->systemTaskService()->createArtifactInstallTask( + $package['name'], + $package['version'], + basename($fileName), + $store->getRequesterSnapshot(), + $store->isSuperAdmin() + ); + + if (empty($response['ok'])) { + @unlink($archive); + + return e(sprintf( + $lang['install_file_artifact_failed'] ?? 'Composer package %1$s could not be queued: %2$s', + $package['name'], + (string) ($response['message'] ?? '') + )); + } + + return e(sprintf( + $lang['install_file_artifact_queued'] ?? 'Composer package %1$s %2$s is queued for installation as system task #%3$s. The scheduler installs it from the uploaded archive.', + $package['name'], + $package['version'], + (string) ($response['task']['id'] ?? '') + )); + } + protected function hasSystemTaskViewAccess($store, array $requesterSnapshot = []) { return $store->isSuperAdmin() diff --git a/core/src/Services/Store/StoreContextService.php b/core/src/Services/Store/StoreContextService.php index 20a5522c78..5225886ba9 100644 --- a/core/src/Services/Store/StoreContextService.php +++ b/core/src/Services/Store/StoreContextService.php @@ -10,13 +10,17 @@ public function loadLanguage(string $modulePath, string $managerLanguage): array $languageFile = rtrim($modulePath, '/\\') . '/lang/' . $managerLanguage . '.php'; $fallbackFile = rtrim($modulePath, '/\\') . '/lang/en.php'; - if (file_exists($languageFile)) { - include $languageFile; - } else { + // English goes in first and the manager language on top of it, so a key the + // translation does not have yet shows in English instead of not at all. + $_Lang = []; + if (file_exists($fallbackFile)) { include $fallbackFile; } + if ($languageFile !== $fallbackFile && file_exists($languageFile)) { + include $languageFile; + } - return isset($_Lang) && is_array($_Lang) ? $_Lang : []; + return is_array($_Lang) ? $_Lang : []; } public function getLanguageCode(string $managerLanguage): string diff --git a/core/src/Services/SystemTasks/ConsoleInstallFlowService.php b/core/src/Services/SystemTasks/ConsoleInstallFlowService.php index c10c3ac103..4f63b4e984 100644 --- a/core/src/Services/SystemTasks/ConsoleInstallFlowService.php +++ b/core/src/Services/SystemTasks/ConsoleInstallFlowService.php @@ -36,6 +36,9 @@ public function execute(SystemCliTask $task, ?callable $report = null) 'value' => $composerVersion, 'composer_run' => 1, '--no-dev' => !$this->vendorHasDevPackages(), + // An uploaded archive is installed from the local artifact repository; + // leaving its dependencies alone lets that work without network access. + '--keep-dependencies' => ($snapshot['source_kind'] ?? '') === 'artifact', ], 'install_require', 30, diff --git a/core/src/Services/SystemTasks/SystemTaskService.php b/core/src/Services/SystemTasks/SystemTaskService.php index 4f87f321f2..eced722639 100644 --- a/core/src/Services/SystemTasks/SystemTaskService.php +++ b/core/src/Services/SystemTasks/SystemTaskService.php @@ -229,6 +229,74 @@ protected function normalizeBooleanRequest($value): bool return !in_array(strtolower(trim((string) $value)), ['0', 'false', 'off', 'no'], true); } + /** + * Queue the install of a Composer package uploaded as an archive. + * + * The package is not in the catalog snapshot, so it passes the same preflight as a + * catalog install and is pinned to the exact version the archive holds. + * + * @since 3.5.9 + * @param string $composerName Composer package name from the archive. + * @param string $version Version from the archive. + * @param string $archiveName Name the archive was uploaded under. + * @param array $requesterSnapshot + * @param bool $isSuperAdmin + * @return array + */ + public function createArtifactInstallTask($composerName, $version, $archiveName = '', array $requesterSnapshot = [], $isSuperAdmin = false) + { + $preflight = $this->runCreatePreflight('console_install', $requesterSnapshot, (bool) $isSuperAdmin); + if (!$preflight['ok']) { + return $preflight; + } + + $composerName = trim((string) $composerName); + $version = trim((string) $version); + if ($composerName === '' || $version === '') { + return [ + 'ok' => false, + 'error_code' => 'SNAPSHOT_INVALID', + 'message' => 'Uploaded package name and version are required.', + ]; + } + + $snapshot = [ + 'task_type' => 'console_install', + 'catalog_source' => 'upload', + 'catalog_fetched_at' => Carbon::now()->toAtomString(), + 'package_type' => 'console-extra', + 'package_name' => basename($composerName), + 'display_title' => $composerName, + 'composer_name' => $composerName, + 'resolved_version' => $version, + 'composer_version' => $version, + 'repo_full_name' => '', + 'source_url' => '', + 'readme_branch' => '', + 'source_kind' => 'artifact', + 'source_label' => (string) $archiveName, + 'capabilities' => [ + 'discover' => true, + 'publish' => true, + 'migrate' => true, + ], + ]; + + $task = $this->persistQueuedTask('console_install', $composerName, $version, $snapshot, $requesterSnapshot); + + $this->appendLog($task, 'info', 'queued', 'Console install task queued from an uploaded archive.', [ + 'composer_name' => $composerName, + 'resolved_version' => $version, + 'archive' => (string) $archiveName, + ]); + + return [ + 'ok' => true, + 'task' => $this->buildTaskPayloadWithLogs($task), + 'warnings' => $preflight['warnings'], + ]; + } + public function createConsoleUninstallTask($catalogItemId, $requestedVersion = '', array $requesterSnapshot = []) { $catalogItem = $this->resolveConsoleCatalogItem($catalogItemId); diff --git a/core/src/Traits/RunsComposerShell.php b/core/src/Traits/RunsComposerShell.php new file mode 100644 index 0000000000..60bd1ca2a4 --- /dev/null +++ b/core/src/Traits/RunsComposerShell.php @@ -0,0 +1,276 @@ +resolveComposerBinaryCommand() ?? 'composer'; + } + + /** + * Find a Composer executable without guessing. + * + * Unlike composerBinaryCommand(), which falls back to a bare "composer" and lets + * the shell report the failure, this answers null when nothing was found, so a + * caller with another way to run Composer can take it instead. + * + * @since 3.5.9 + * @return string|null Composer command safe for shell usage, or null. + */ + protected function resolveComposerBinaryCommand(): ?string + { + foreach (['COMPOSER_BINARY', 'COMPOSER_BIN'] as $envName) { + $configured = trim((string) getenv($envName)); + if ($configured !== '') { + return escapeshellarg($configured); + } + } + + if ($this->shellCommandExists('composer')) { + return 'composer'; + } + + foreach ($this->composerBinaryCandidates() as $candidate) { + if ($this->isExecutableFile($candidate)) { + return escapeshellarg($candidate); + } + } + + return null; + } + + /** + * Check whether a path is something the shell can run. + * + * On Windows is_executable() answers false even for a genuine + * composer.bat — it does not consult PATHEXT the way the shell does — so + * every candidate would be rejected no matter which paths were offered. + * There the file existing is the only signal available. + * + * @since 3.5.8 + * @param string $path Absolute path to test. + * @return bool + */ + protected function isExecutableFile(string $path): bool + { + if (!is_file($path)) { + return false; + } + + return windows_os() ? true : is_executable($path); + } + + /** + * Build fallback Composer executable candidates. + * + * @since 3.5.7 + * @return array + */ + protected function composerBinaryCandidates(): array + { + $candidates = [ + '/usr/local/bin/composer', + '/usr/bin/composer', + ]; + + foreach ($this->homeDirectories() as $home) { + $candidates[] = $home . '/.composer/composer'; + } + + // Appended rather than switched on the platform. Every candidate is + // filtered by isExecutableFile() anyway, so an entry that cannot exist + // here costs one is_file() call, while a platform branch would be a + // new way to guess wrong — under WSL, or wherever the environment does + // not match what PHP_OS_FAMILY suggests. + $candidates = array_merge($candidates, $this->windowsComposerBinaryCandidates()); + + return array_values(array_unique($candidates)); + } + + /** + * Build fallback Composer executable candidates for Windows layouts. + * + * The POSIX list finds nothing here: there is no /usr/local/bin, and a + * per-user install puts a shim in %APPDATA%\Composer rather than in a + * ~/.composer/composer file. Only shell-runnable shims are listed — + * composer.phar is deliberately absent, because it needs `php` in front of + * it and this list feeds a command that is executed directly. + * + * @since 3.5.8 + * @return array + */ + protected function windowsComposerBinaryCandidates(): array + { + $candidates = []; + + // Where the Composer-Setup installer puts a machine-wide install. + $programData = trim((string) getenv('ProgramData')); + if ($programData !== '') { + $base = rtrim(str_replace('\\', '/', $programData), '/') . '/ComposerSetup/bin/composer'; + $candidates[] = $base . '.bat'; + $candidates[] = $base . '.exe'; + } + + // A per-user install. + $appData = trim((string) getenv('APPDATA')); + if ($appData !== '') { + $base = rtrim(str_replace('\\', '/', $appData), '/') . '/Composer/composer'; + $candidates[] = $base . '.bat'; + $candidates[] = $base . '.exe'; + } + + foreach ($this->homeDirectories() as $home) { + $candidates[] = $home . '/AppData/Roaming/Composer/composer.bat'; + } + + return array_values(array_unique(array_filter($candidates))); + } + + /** + * Resolve possible home directories without relying on shell "~" expansion. + * + * @since 3.5.7 + * @return array + */ + protected function homeDirectories(): array + { + $homes = []; + + foreach (['HOME', 'USERPROFILE'] as $envName) { + $home = trim((string) getenv($envName)); + if ($home !== '') { + $homes[] = rtrim(str_replace('\\', '/', $home), '/'); + } + } + + if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { + $user = posix_getpwuid(posix_getuid()); + if (is_array($user) && !empty($user['dir'])) { + $homes[] = rtrim(str_replace('\\', '/', (string) $user['dir']), '/'); + } + } + + return array_values(array_unique(array_filter($homes))); + } + + /** + * Check whether a command is available to the non-interactive shell. + * + * @since 3.5.7 + * @param string $command Command name. + * @return bool + */ + protected function shellCommandExists(string $command): bool + { + $output = []; + $exitCode = 1; + + // `command -v` is a POSIX shell builtin and /dev/null is a POSIX + // device; cmd.exe has neither, so on Windows this probe reported "not + // found" for every command — including ones plainly on PATH — and the + // resolver fell through to candidate paths that do not exist there + // either. `where` is the native equivalent and answers 0 when found. + $probe = windows_os() + ? 'where ' . escapeshellarg($command) . ' >NUL 2>NUL' + : 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1'; + + ExecWithFallback::exec($probe, $output, $exitCode); + + return (int) $exitCode === 0; + } + + + /** + * Run a shell command from the core directory and report how it went. + * + * Composer and artisan calls must not rely on the current working directory: + * updates are started from the manager, cron or a shell anywhere on the disk. + * + * @since 3.5.9 + * @param string $command Command to execute from EVO_CORE_PATH. + * @param array|null $output Receives the combined stdout and stderr lines. + * @return int Exit code of the command. + */ + protected function execCoreShellCommand(string $command, ?array &$output = null): int + { + // Plain `cd` keeps the current drive on Windows; /d switches it too. + $cd = windows_os() ? 'cd /d ' : 'cd '; + $fullCommand = $cd . escapeshellarg(EVO_CORE_PATH) . ' && ' . $command . ' 2>&1'; + + $output = []; + $exitCode = 1; + ExecWithFallback::exec($fullCommand, $output, $exitCode); + + return (int) $exitCode; + } + + /** + * PHP executable for commands that start a PHP script. + * + * PHP_BINARY names the running interpreter only on the command line; under + * PHP-FPM it is the FPM daemon, which cannot run a script, so the PHP on PATH + * is used there instead. + * + * @since 3.5.9 + * @return string PHP command safe for shell usage. + */ + protected function phpBinaryCommand(): string + { + if (PHP_SAPI === 'cli' && PHP_BINARY !== '') { + return escapeshellarg(PHP_BINARY); + } + + return 'php'; + } + + /** + * Read the version of the Composer a command runs. + * + * @since 3.5.9 + * @param string $composerCommand Composer command safe for shell usage. + * @return string|null Version such as "2.8.4", or null when it could not be read. + */ + protected function composerVersion(string $composerCommand): ?string + { + $output = []; + if ($this->execCoreShellCommand($composerCommand . ' --version --no-ansi', $output) !== 0) { + return null; + } + + return self::parseComposerVersion(implode("\n", $output)); + } + + /** + * Pick the version out of `composer --version` output. + * + * @since 3.5.9 + * @param string $output Output of `composer --version`. + * @return string|null + */ + public static function parseComposerVersion(string $output): ?string + { + if (preg_match('~Composer (?:version )?v?(\d+\.\d+(?:\.\d+)?)~i', $output, $matches) !== 1) { + return null; + } + + return $matches[1]; + } +} diff --git a/core/tests/Unit/Console/PackageRequireCommandTest.php b/core/tests/Unit/Console/PackageRequireCommandTest.php index 42dd869e72..5170dc9305 100644 --- a/core/tests/Unit/Console/PackageRequireCommandTest.php +++ b/core/tests/Unit/Console/PackageRequireCommandTest.php @@ -92,8 +92,10 @@ function setPackageRequireComposerPath(InstallPackageRequireCommand $command, st $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Console/Packages/InstallPackageRequireCommand.php'); expect($source) - ->toContain("hasCommandOption('no-dev')") - ->toContain("hasCommandOption('optimize-autoloader')"); + ->toContain("commandOptionEnabled('no-dev')") + ->toContain("commandOptionEnabled('optimize-autoloader')") + ->toContain("commandOptionEnabled('keep-dependencies')") + ->toContain('return $this->hasCommandOption($name) && (bool) $this->option($name);'); }); test('package install require scopes composer update to the installed package', function () { @@ -153,3 +155,221 @@ function setPackageRequireComposerPath(InstallPackageRequireCommand $command, st expect($command->buildComposerArguments())->toBe(['command' => 'update']); }); + +/** + * Install command whose Composer and boot check are scripted, so the rollback can be + * exercised without touching the real vendor directory. + */ +final class ScriptedPackageRequireCommand extends InstallPackageRequireCommand +{ + public int $updateExitCode = 0; + public bool $boots = true; + public array $composerRuns = []; + public ?string $lockDuringReinstall = null; + + public function runComposer() + { + $this->composerRuns[] = $this->buildComposerArguments(); + // What Composer does to the lock file before it fails. + file_put_contents($this->composerLock, '{"new":true}'); + + return $this->updateExitCode; + } + + protected function applicationBoots(): bool + { + return $this->boots; + } + + protected function composerProcessCommand(): ?string + { + return 'composer'; + } + + protected function runComposerProcess(string $composerCommand, array $arguments): int + { + $this->composerRuns[] = $arguments; + $this->lockDuringReinstall = (string) file_get_contents($this->composerLock); + + return 0; + } +} + +function makeScriptedPackageRequireCommand(string $composer, string $lock): ScriptedPackageRequireCommand +{ + $command = new ScriptedPackageRequireCommand(); + setPackageRequireComposerPath($command, $composer); + + $property = new ReflectionProperty(InstallPackageRequireCommand::class, 'composerLock'); + $property->setAccessible(true); + $property->setValue($command, $lock); + + return $command; +} + +test('a failed composer update restores composer.json and the lock file, then reinstalls from it', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + $lock = tempnam(sys_get_temp_dir(), 'evo-lock-'); + $originalComposer = json_encode(['name' => 'evolutioncms/custom', 'require' => ['seiger/sseo' => '*']]); + file_put_contents($composer, $originalComposer); + file_put_contents($lock, '{"old":true}'); + + $command = makeScriptedPackageRequireCommand($composer, $lock); + $command->updateExitCode = 2; + + $tester = new CommandTester($command); + $exitCode = $tester->execute(['key' => 'seiger/sgallery', 'value' => '*', '--no-dev' => true]); + + expect($exitCode)->toBe(2) + ->and(file_get_contents($composer))->toBe($originalComposer) + ->and(file_get_contents($lock))->toBe('{"old":true}') + ->and($command->lockDuringReinstall)->toBe('{"old":true}') + ->and($command->composerRuns[1])->toBe(['command' => 'install', '--no-dev' => true]) + ->and($tester->getDisplay())->toContain('restoring the previous dependencies'); + + @unlink($composer); + @unlink($lock); +}); + +test('an update after which the site no longer boots is rolled back as well', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + $lock = tempnam(sys_get_temp_dir(), 'evo-lock-'); + file_put_contents($composer, json_encode(['name' => 'evolutioncms/custom', 'require' => []])); + file_put_contents($lock, '{"old":true}'); + + $command = makeScriptedPackageRequireCommand($composer, $lock); + $command->boots = false; + + $tester = new CommandTester($command); + $exitCode = $tester->execute(['key' => 'seiger/sgallery', 'value' => '*']); + + $composerData = json_decode((string) file_get_contents($composer), true); + + expect($exitCode)->toBe(1) + ->and($composerData['require'])->not->toHaveKey('seiger/sgallery') + ->and(file_get_contents($lock))->toBe('{"old":true}') + ->and($command->composerRuns[1])->toBe(['command' => 'install']) + ->and($tester->getDisplay())->toContain('no longer boots'); + + @unlink($composer); + @unlink($lock); +}); + +test('a successful update keeps the new requirement and lock file', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + $lock = tempnam(sys_get_temp_dir(), 'evo-lock-'); + file_put_contents($composer, json_encode(['name' => 'evolutioncms/custom', 'require' => []])); + file_put_contents($lock, '{"old":true}'); + + $command = makeScriptedPackageRequireCommand($composer, $lock); + + $exitCode = (new CommandTester($command))->execute(['key' => 'seiger/sgallery', 'value' => '1.5.2']); + + $composerData = json_decode((string) file_get_contents($composer), true); + + expect($exitCode)->toBe(0) + ->and($composerData['require'])->toBe(['seiger/sgallery' => '1.5.2']) + ->and(file_get_contents($lock))->toBe('{"new":true}') + ->and($command->composerRuns)->toHaveCount(1); + + @unlink($composer); + @unlink($lock); +}); + +test('composer update asks for minimal changes when the composer knows the option', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + file_put_contents($composer, json_encode(['name' => 'evolutioncms/custom', 'require' => []])); + + $command = new InstallPackageRequireCommand(); + setPackageRequireComposerPath($command, $composer); + (new CommandTester($command))->execute(['key' => 'seiger/sgallery', 'value' => '*', 'composer_run' => '0']); + + expect($command->buildComposerArguments(true))->toBe([ + 'command' => 'update', + 'packages' => ['seiger/sgallery'], + '--with-dependencies' => true, + '--minimal-changes' => true, + ]); + + @unlink($composer); +}); + +test('keep-dependencies leaves the dependencies of an uploaded package alone', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + file_put_contents($composer, json_encode(['name' => 'evolutioncms/custom', 'require' => []])); + + $command = new InstallPackageRequireCommand(); + setPackageRequireComposerPath($command, $composer); + (new CommandTester($command))->execute([ + 'key' => 'seiger/sgallery', + 'value' => '1.5.2', + 'composer_run' => '0', + '--keep-dependencies' => true, + ]); + + expect($command->buildComposerArguments())->toBe([ + 'command' => 'update', + 'packages' => ['seiger/sgallery'], + ]); + + @unlink($composer); +}); + +test('composer arguments become an escaped non-interactive command line', function () { + $command = new InstallPackageRequireCommand(); + + $line = $command->buildComposerShellArguments([ + 'command' => 'update', + 'packages' => ['seiger/sgallery', 'vendor/with space'], + '--with-dependencies' => true, + '--no-dev' => true, + '--optimize-autoloader' => false, + ]); + + expect($line)->toBe(implode(' ', [ + escapeshellarg('update'), + escapeshellarg('seiger/sgallery'), + escapeshellarg('vendor/with space'), + '--with-dependencies', + '--no-dev', + '--no-interaction', + '--no-ansi', + ])); +}); + +test('minimal changes is used only from composer 2.7 on', function (?string $version, bool $expected) { + expect((new InstallPackageRequireCommand())->supportsMinimalChanges($version))->toBe($expected); +})->with([ + [null, false], + ['2.6.6', false], + ['2.7.0', true], + ['2.8.12', true], + ['3.0', true], +]); + +test('composer version is read from the version banner', function (string $output, ?string $expected) { + expect(InstallPackageRequireCommand::parseComposerVersion($output))->toBe($expected); +})->with([ + ['Composer version 2.8.12 2025-09-19 13:41:59', '2.8.12'], + ["PHP version 8.4.1\nComposer version 2.7.0 2024-02-08", '2.7.0'], + ['Composer 2.2.18', '2.2.18'], + ['command not found', null], +]); + +test('autoload registration rebuilds the autoloader instead of updating every package', function () { + $command = new \EvolutionCMS\Console\Packages\InstallPackageAutoloadCommand(); + + expect($command->buildComposerArguments(true))->toBe(['command' => 'dump-autoload']); +}); + +test('composer runs in-process only when no process can be started', function () { + $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Console/Packages/InstallPackageRequireCommand.php'); + $runComposer = substr($source, strpos($source, 'public function runComposer()'), 1200); + + expect($runComposer) + ->toContain('$composerCommand = $this->composerProcessCommand();') + ->toContain('if ($composerCommand === null) {') + ->toContain('return $this->runComposerProcess($composerCommand, $arguments);') + ->and(substr_count($source, 'new Application()'))->toBe(1) + ->and($source)->toContain('if (!ExecWithFallback::anyAvailable()) {'); +}); diff --git a/core/tests/Unit/Console/SiteUpdateCommandTest.php b/core/tests/Unit/Console/SiteUpdateCommandTest.php index 755cc3614d..1d023c00ab 100644 --- a/core/tests/Unit/Console/SiteUpdateCommandTest.php +++ b/core/tests/Unit/Console/SiteUpdateCommandTest.php @@ -217,7 +217,7 @@ function invokeSiteUpdateMethod(SiteUpdateCommand $command, string $method, arra }); test('composer detection uses a probe the local shell actually understands', function () { - $source = file_get_contents(dirname(__DIR__, 3) . '/src/Console/SiteUpdateCommand.php'); + $source = file_get_contents(dirname(__DIR__, 3) . '/src/Traits/RunsComposerShell.php'); expect($source) ->toContain("'where ' . escapeshellarg(\$command) . ' >NUL 2>NUL'") diff --git a/core/tests/Unit/Install/CliInstallExecAutoloadTest.php b/core/tests/Unit/Install/CliInstallExecAutoloadTest.php index 4345e860f6..32b0b811b3 100644 --- a/core/tests/Unit/Install/CliInstallExecAutoloadTest.php +++ b/core/tests/Unit/Install/CliInstallExecAutoloadTest.php @@ -45,3 +45,12 @@ expect(strpos($installer, 'loadExecWithFallback()')) ->toBeLessThan(strpos($installer, 'ExecWithFallback::exec($cmd')); }); + +it('reports a failed composer update instead of passing over it', function () use ($root) { + $installer = (string) file_get_contents($root . '/install/cli-install.php'); + $call = strpos($installer, 'ExecWithFallback::exec($cmd, $out, $exitCode);'); + + expect($call)->not->toBeFalse() + ->and(strpos($installer, 'if ((int) $exitCode !== 0) {', $call))->toBeGreaterThan($call) + ->and($installer)->toContain('the dependencies shipped with the archive are kept'); +}); diff --git a/core/tests/Unit/Store/ComposerArtifactServiceTest.php b/core/tests/Unit/Store/ComposerArtifactServiceTest.php new file mode 100644 index 0000000000..28748df9ed --- /dev/null +++ b/core/tests/Unit/Store/ComposerArtifactServiceTest.php @@ -0,0 +1,189 @@ + contents. + */ +function makeArtifactTestZip(array $entries): string +{ + $path = tempnam(sys_get_temp_dir(), 'evo-artifact-') . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + $zip->close(); + + return $path; +} + +function makeArtifactTestCore(): string +{ + $core = sys_get_temp_dir() . '/evo-artifact-core-' . uniqid(); + mkdir($core . '/custom', 0777, true); + + return $core; +} + +function removeArtifactTestDir(string $dir): void +{ + if (!is_dir($dir)) { + return; + } + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); +} + +test('inspect reads a package from composer.json at the archive root', function () { + $zip = makeArtifactTestZip([ + 'composer.json' => json_encode(['name' => 'seiger/sgallery', 'version' => '1.5.2']), + 'src/Gallery.php' => 'inspect($zip, 'upload.zip'); + + expect($package['name'])->toBe('seiger/sgallery') + ->and($package['version'])->toBe('1.5.2') + ->and($package['entry'])->toBe('composer.json'); + + @unlink($zip); +}); + +test('inspect finds composer.json inside the only top-level directory, as in a GitHub download', function () { + $zip = makeArtifactTestZip([ + 'sgallery-1.5.2/composer.json' => json_encode(['name' => 'seiger/sgallery']), + 'sgallery-1.5.2/src/Gallery.php' => 'inspect($zip, 'sgallery-v1.5.2.zip'); + + expect($package['name'])->toBe('seiger/sgallery') + ->and($package['version'])->toBe('1.5.2') + ->and($package['entry'])->toBe('sgallery-1.5.2/composer.json'); + + @unlink($zip); +}); + +test('inspect reports an unknown version as empty instead of guessing', function () { + $zip = makeArtifactTestZip([ + 'composer.json' => json_encode(['name' => 'seiger/sgallery']), + ]); + + $package = (new ComposerArtifactService(sys_get_temp_dir()))->inspect($zip, 'sgallery.zip'); + + expect($package['version'])->toBe(''); + + @unlink($zip); +}); + +test('inspect leaves legacy extras with an install directory to the legacy installer', function () { + $zip = makeArtifactTestZip([ + 'mypackage/composer.json' => json_encode(['name' => 'vendor/mypackage']), + 'mypackage/install/assets/snippets/my.tpl' => '//', + ]); + + expect((new ComposerArtifactService(sys_get_temp_dir()))->inspect($zip, 'mypackage.zip'))->toBeNull(); + + @unlink($zip); +}); + +test('inspect ignores archives that are not shaped like a composer package', function (array $entries) { + $zip = makeArtifactTestZip($entries); + + expect((new ComposerArtifactService(sys_get_temp_dir()))->inspect($zip, 'x-1.0.0.zip'))->toBeNull(); + + @unlink($zip); +})->with([ + 'no composer.json' => [['assets/snippets/x.php' => ' [['a/composer.json' => json_encode(['name' => 'a/b']), 'b/readme.md' => '']], +]); + +test('inspect flags a composer package whose composer.json cannot be used', function (string $composerJson) { + $zip = makeArtifactTestZip(['pkg/composer.json' => $composerJson, 'pkg/src/A.php' => 'inspect($zip, 'pkg-1.0.0.zip'); + + expect($package['invalid'])->toBeTrue() + ->and($package['entry'])->toBe('pkg/composer.json') + ->and($package['name'])->toBe(''); + + @unlink($zip); +})->with([ + 'no name' => [json_encode(['require' => []])], + 'invalid name' => [json_encode(['name' => 'Not A Package'])], + 'broken json' => ['{"autoload": {"psr-4": {"Demo\Hello\\": "src/"}}}'], +]); + +test('a valid package is not flagged as invalid', function () { + $zip = makeArtifactTestZip(['composer.json' => json_encode(['name' => 'seiger/sgallery', 'version' => '1.5.2'])]); + + expect((new ComposerArtifactService(sys_get_temp_dir()))->inspect($zip, 'x.zip')['invalid'])->toBeFalse(); + + @unlink($zip); +}); + +test('store keeps the archive, writes a missing version into it and registers the repository once', function () { + $core = makeArtifactTestCore(); + file_put_contents($core . '/custom/composer.json', json_encode([ + 'name' => 'evolutioncms/custom', + 'require' => ['seiger/sseo' => '*'], + ])); + $zip = makeArtifactTestZip([ + 'sgallery-main/composer.json' => json_encode(['name' => 'seiger/sgallery', 'type' => 'library']), + ]); + + $service = new ComposerArtifactService($core); + $package = $service->inspect($zip, 'sgallery-1.5.2.zip'); + $stored = $service->store($zip, $package); + $service->store($zip, $package); + + $archive = new ZipArchive(); + $archive->open($stored); + $composer = json_decode((string) $archive->getFromName('sgallery-main/composer.json'), true); + $archive->close(); + + $custom = json_decode((string) file_get_contents($core . '/custom/composer.json'), true); + + expect(basename($stored))->toBe('seiger-sgallery-1.5.2.zip') + ->and(dirname($stored))->toBe($core . '/custom/artifacts') + ->and($composer['version'])->toBe('1.5.2') + ->and($composer['type'])->toBe('library') + ->and($custom['require'])->toBe(['seiger/sseo' => '*']) + ->and($custom['repositories'])->toBe([['type' => 'artifact', 'url' => 'custom/artifacts']]); + + @unlink($zip); + removeArtifactTestDir($core); +}); + +test('registerRepository creates custom/composer.json when there is none', function () { + $core = makeArtifactTestCore(); + + (new ComposerArtifactService($core))->registerRepository(); + + $custom = json_decode((string) file_get_contents($core . '/custom/composer.json'), true); + + expect($custom['name'])->toBe('evolutioncms/custom') + ->and($custom['repositories'])->toBe([['type' => 'artifact', 'url' => 'custom/artifacts']]); + + removeArtifactTestDir($core); +}); + +test('versionFromFileName reads common archive names', function (string $fileName, ?string $expected) { + expect(ComposerArtifactService::versionFromFileName($fileName))->toBe($expected); +})->with([ + ['sgallery-1.5.2.zip', '1.5.2'], + ['sgallery-v1.5.2.zip', '1.5.2'], + ['seiger_sgallery_2.0.zip', '2.0'], + ['sgallery-2.0.0-beta.1.zip', '2.0.0-beta.1'], + ['/tmp/x/sgallery-1.0.0.ZIP', '1.0.0'], + ['sgallery.zip', null], + ['sgallery-main.zip', null], + ['sgallery1.5.2.zip', null], +]); diff --git a/core/tests/Unit/Store/StoreLanguageTest.php b/core/tests/Unit/Store/StoreLanguageTest.php new file mode 100644 index 0000000000..e87ba703c0 --- /dev/null +++ b/core/tests/Unit/Store/StoreLanguageTest.php @@ -0,0 +1,72 @@ + basename($file, '.php'), glob($dir . '/*.php')); + sort($codes); + + return $codes; +} + +test('the store has a language file for every manager language', function () use ($storeModule) { + $managerLanguages = array_map('basename', glob(dirname(__DIR__, 3) . '/lang/*', GLOB_ONLYDIR)); + sort($managerLanguages); + + expect(storeLanguageCodes($storeModule . '/lang'))->toBe($managerLanguages); +}); + +function storeLanguageStrings(string $file): array +{ + $_Lang = []; + include $file; + + return $_Lang; +} + +test('every store language translates the composer archive messages', function (string $key) use ($storeModule) { + $english = storeLanguageStrings($storeModule . '/lang/en.php')[$key]; + + foreach (storeLanguageCodes($storeModule . '/lang') as $code) { + $strings = storeLanguageStrings($storeModule . '/lang/' . $code . '.php'); + + expect(isset($strings[$key]))->toBeTrue($code . ' is missing ' . $key); + expect(substr_count($strings[$key], '%')) + ->toBe(substr_count($english, '%'), $code . ' ' . $key . ' has other placeholders than en'); + } +})->with([ + 'install_file_artifact_queued', + 'install_file_artifact_no_version', + 'install_file_artifact_failed', + 'install_file_artifact_invalid', +]); + +test('a key missing from a translation falls back to english', function () use ($storeModule) { + $english = (new StoreContextService())->loadLanguage($storeModule, 'en'); + $slovak = (new StoreContextService())->loadLanguage($storeModule, 'sk'); + $german = (new StoreContextService())->loadLanguage($storeModule, 'de'); + + expect(array_diff_key($english, $slovak))->toBe([]) + ->and(array_diff_key($english, $german))->toBe([]) + ->and($slovak['install_file'])->toBe('Inštalácia z archívu') + ->and($german['install_file'])->toBe($english['install_file']) + ->and($german['install_file_artifact_failed'])->toStartWith('Das Composer-Paket'); +}); + +test('an unknown manager language loads english', function () use ($storeModule) { + expect((new StoreContextService())->loadLanguage($storeModule, 'xx')) + ->toBe((new StoreContextService())->loadLanguage($storeModule, 'en')); +}); + +test('no store translation carries keys that english does not have', function () use ($storeModule) { + $english = storeLanguageStrings($storeModule . '/lang/en.php'); + + foreach (storeLanguageCodes($storeModule . '/lang') as $code) { + $extra = array_keys(array_diff_key(storeLanguageStrings($storeModule . '/lang/' . $code . '.php'), $english)); + + expect($extra)->toBe([], $code . ' has keys that are not in en: ' . implode(', ', $extra)); + } +}); diff --git a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php index 9792082452..acc2e51c63 100644 --- a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php +++ b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php @@ -85,3 +85,62 @@ function invokeConsoleInstallFlowMethod(ConsoleInstallFlowService $service, stri ->and(invokeConsoleInstallFlowMethod($service, 'summarizeOutput', [$output, true])) ->toContain('ext-imagick'); }); + +/** + * Flow service that records the artisan commands it would run instead of running them. + */ +final class RecordingConsoleInstallFlowService extends ConsoleInstallFlowService +{ + public array $commands = []; + + protected function runArtisanCommand($command, array $arguments, $step, $progress, $message, ?callable $report = null) + { + $this->commands[] = [$command, $arguments]; + } + + protected function getPublishProviders($packageName) + { + return []; + } +} + +function installRequireArgumentsFor(array $payload): array +{ + if (!defined('EVO_CORE_PATH')) { + define('EVO_CORE_PATH', dirname(__DIR__, 3) . '/'); + } + + $task = new \EvolutionCMS\Models\SystemCliTask(); + $task->target = $payload['composer_name']; + $task->requested_version = $payload['resolved_version']; + $task->payload_json = $payload; + + $service = new RecordingConsoleInstallFlowService(); + $service->execute($task); + + return $service->commands[0][1]; +} + +test('an uploaded archive is installed without updating installed dependencies', function () { + $arguments = installRequireArgumentsFor([ + 'composer_name' => 'evodemo/hello-evo', + 'resolved_version' => '1.0.0', + 'composer_version' => '1.0.0', + 'source_kind' => 'artifact', + ]); + + expect($arguments['key'])->toBe('evodemo/hello-evo') + ->and($arguments['value'])->toBe('1.0.0') + ->and($arguments['--keep-dependencies'])->toBeTrue(); +}); + +test('a catalog package still updates its dependencies', function () { + $arguments = installRequireArgumentsFor([ + 'composer_name' => 'seiger/sgallery', + 'resolved_version' => 'v1.5.2', + 'composer_version' => 'v1.5.2', + 'source_kind' => 'console', + ]); + + expect($arguments['--keep-dependencies'])->toBeFalse(); +}); diff --git a/core/tests/Unit/SystemTasks/ModuleActionServiceTest.php b/core/tests/Unit/SystemTasks/ModuleActionServiceTest.php index 377b4033ec..a5f986156e 100644 --- a/core/tests/Unit/SystemTasks/ModuleActionServiceTest.php +++ b/core/tests/Unit/SystemTasks/ModuleActionServiceTest.php @@ -1,7 +1,159 @@ package; + } + + public function store(string $zipPath, array $package): string + { + $this->stored[] = $package['name']; + $this->storedPath = tempnam(sys_get_temp_dir(), 'evo-stored-'); + + return $this->storedPath; + } +} + +/** + * Store stand-in that records the artifact install task it is asked to queue. + */ +function makeArtifactUploadStore(array $taskResponse): object +{ + return new class($taskResponse) { + public array $lang = []; + public array $taskCalls = []; + + public function __construct(private array $taskResponse) + { + } + + public function isSuperAdmin() + { + return false; + } + + public function getRequesterSnapshot() + { + return ['permissions' => ['exec_module' => 1]]; + } + + public function systemTaskService() + { + $store = $this; + + return new class($store) { + public function __construct(private object $store) + { + } + + public function createArtifactInstallTask($name, $version, $archiveName = '', array $requester = [], $isSuperAdmin = false) + { + $this->store->taskCalls[] = [$name, $version, $archiveName]; + + return $this->store->taskResponse(); + } + }; + } + + public function taskResponse(): array + { + return $this->taskResponse; + } + }; +} + +function queueArtifactUpload(object $store, ScriptedComposerArtifactService $artifacts, string $fileName = 'hello-evo-1.0.0.zip') +{ + $method = new ReflectionMethod(ModuleActionService::class, 'queueComposerArtifactFile'); + $method->setAccessible(true); + + return $method->invoke(new ModuleActionService(), $store, $artifacts, '/tmp/upload.zip', $fileName); +} + +function artifactPackage(array $overrides = []): array +{ + return array_merge([ + 'name' => 'evodemo/hello-evo', + 'version' => '1.0.0', + 'entry' => 'hello-evo/composer.json', + 'composer' => ['name' => 'evodemo/hello-evo'], + 'invalid' => false, + ], $overrides); +} + +test('an uploaded composer package is stored and queued as a console install', function () { + $store = makeArtifactUploadStore(['ok' => true, 'task' => ['id' => 42]]); + $artifacts = new ScriptedComposerArtifactService(); + $artifacts->package = artifactPackage(); + + $body = queueArtifactUpload($store, $artifacts); + + expect($body)->toContain('evodemo/hello-evo 1.0.0') + ->and($body)->toContain('#42') + ->and($artifacts->stored)->toBe(['evodemo/hello-evo']) + ->and($store->taskCalls)->toBe([['evodemo/hello-evo', '1.0.0', 'hello-evo-1.0.0.zip']]) + ->and(is_file($artifacts->storedPath))->toBeTrue(); + + @unlink($artifacts->storedPath); +}); + +test('a legacy upload is left to the legacy installer', function () { + $store = makeArtifactUploadStore(['ok' => true, 'task' => ['id' => 1]]); + $artifacts = new ScriptedComposerArtifactService(); + + expect(queueArtifactUpload($store, $artifacts))->toBeNull() + ->and($store->taskCalls)->toBe([]); +}); + +test('an invalid composer package is refused instead of being copied into the web root', function () { + $store = makeArtifactUploadStore(['ok' => true, 'task' => ['id' => 1]]); + $artifacts = new ScriptedComposerArtifactService(); + $artifacts->package = artifactPackage(['name' => '', 'version' => '', 'composer' => [], 'invalid' => true]); + + $body = queueArtifactUpload($store, $artifacts); + + expect($body)->toContain('hello-evo/composer.json') + ->and($body)->toContain('Nothing was installed') + ->and($artifacts->stored)->toBe([]) + ->and($store->taskCalls)->toBe([]); +}); + +test('a composer package without a version asks for one and queues nothing', function () { + $store = makeArtifactUploadStore(['ok' => true, 'task' => ['id' => 1]]); + $artifacts = new ScriptedComposerArtifactService(); + $artifacts->package = artifactPackage(['version' => '']); + + $body = queueArtifactUpload($store, $artifacts, 'hello-evo.zip'); + + expect($body)->toContain('hello-evo-1.0.0.zip') + ->and($artifacts->stored)->toBe([]) + ->and($store->taskCalls)->toBe([]); +}); + +test('a refused task removes the stored archive again', function () { + $store = makeArtifactUploadStore(['ok' => false, 'error_code' => 'ACL_DENIED', 'message' => 'Denied here']); + $artifacts = new ScriptedComposerArtifactService(); + $artifacts->package = artifactPackage(); + + $body = queueArtifactUpload($store, $artifacts); + + expect($body)->toContain('could not be queued') + ->and($body)->toContain('Denied <b>here</b>') + ->and(is_file($artifacts->storedPath))->toBeFalse(); +}); + function decodeModuleActionJsonResponse(array $response): array { expect($response['handled'] ?? false)->toBeTrue() diff --git a/core/tests/Unit/SystemTasks/SystemTaskServiceTest.php b/core/tests/Unit/SystemTasks/SystemTaskServiceTest.php index b87848aa44..eb37910cb3 100644 --- a/core/tests/Unit/SystemTasks/SystemTaskServiceTest.php +++ b/core/tests/Unit/SystemTasks/SystemTaskServiceTest.php @@ -831,3 +831,46 @@ public function execute(SystemCliTask $task, ?callable $report = null) ]) ->and($tester->getDisplay())->toContain('[system:task-worker] custom.worker_test task completed'); }); + +test('artifact install task pins the uploaded package and version', function () { + (new SchedulerHealthService())->recordHeartbeat('tests', 'manual'); + + $service = new SystemTaskService(); + $response = $service->createArtifactInstallTask('seiger/sgallery', '1.5.2', 'sgallery-1.5.2.zip', [ + 'user_id' => 7, + 'permissions' => [ + 'exec_module' => true, + 'system_tasks.view' => 1, + 'system_tasks.manage_packages' => 1, + ], + ], false); + + expect($response['ok'])->toBeTrue() + ->and($response['task']['type'])->toBe('console_install') + ->and($response['task']['target'])->toBe('seiger/sgallery') + ->and($response['task']['source_kind'])->toBe('artifact') + ->and($response['task']['source_label'])->toBe('sgallery-1.5.2.zip'); + + $task = SystemCliTask::query()->find($response['task']['id']); + expect($task->payload_json['composer_name'])->toBe('seiger/sgallery') + ->and($task->payload_json['composer_version'])->toBe('1.5.2') + ->and($task->payload_json['resolved_version'])->toBe('1.5.2') + ->and($task->payload_json['catalog_source'])->toBe('upload'); +}); + +test('artifact install task passes the same preflight as a catalog install', function () { + (new SchedulerHealthService())->recordHeartbeat('tests', 'manual'); + + $service = new SystemTaskService(); + $response = $service->createArtifactInstallTask('seiger/sgallery', '1.5.2', 'sgallery-1.5.2.zip', [ + 'user_id' => 7, + 'permissions' => [ + 'exec_module' => true, + 'system_tasks.manage_packages' => 0, + ], + ], false); + + expect($response['ok'])->toBeFalse() + ->and($response['error_code'])->toBe('ACL_DENIED') + ->and(SystemCliTask::query()->count())->toBe(0); +}); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index d0ab936a4d..63c8a901f7 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1376,6 +1376,7 @@ 'EvolutionCMS\\Services\\ManagerActivity' => $baseDir . '/src/Services/ManagerActivity.php', 'EvolutionCMS\\Services\\PasswordRecoveryService' => $baseDir . '/src/Services/PasswordRecoveryService.php', 'EvolutionCMS\\Services\\Store\\CatalogService' => $baseDir . '/src/Services/Store/CatalogService.php', + 'EvolutionCMS\\Services\\Store\\ComposerArtifactService' => $baseDir . '/src/Services/Store/ComposerArtifactService.php', 'EvolutionCMS\\Services\\Store\\InstalledStateService' => $baseDir . '/src/Services/Store/InstalledStateService.php', 'EvolutionCMS\\Services\\Store\\LegacyDeleteService' => $baseDir . '/src/Services/Store/LegacyDeleteService.php', 'EvolutionCMS\\Services\\Store\\ModuleActionService' => $baseDir . '/src/Services/Store/ModuleActionService.php', @@ -1448,6 +1449,7 @@ 'EvolutionCMS\\Traits\\Models\\SoftDeletes' => $baseDir . '/src/Traits/Models/SoftDeletes.php', 'EvolutionCMS\\Traits\\Models\\TimeMutator' => $baseDir . '/src/Traits/Models/TimeMutator.php', 'EvolutionCMS\\Traits\\Path' => $baseDir . '/src/Traits/Path.php', + 'EvolutionCMS\\Traits\\RunsComposerShell' => $baseDir . '/src/Traits/RunsComposerShell.php', 'EvolutionCMS\\Traits\\Settings' => $baseDir . '/src/Traits/Settings.php', 'EvolutionCMS\\UrlProcessor' => $baseDir . '/src/UrlProcessor.php', 'EvolutionCMS\\UserManager\\Facades\\UserManager' => $vendorDir . '/evolutioncms-services/user-manager/src/Facades/UserManager.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 46575f039a..ac86e7d958 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2053,6 +2053,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Services\\ManagerActivity' => __DIR__ . '/../..' . '/src/Services/ManagerActivity.php', 'EvolutionCMS\\Services\\PasswordRecoveryService' => __DIR__ . '/../..' . '/src/Services/PasswordRecoveryService.php', 'EvolutionCMS\\Services\\Store\\CatalogService' => __DIR__ . '/../..' . '/src/Services/Store/CatalogService.php', + 'EvolutionCMS\\Services\\Store\\ComposerArtifactService' => __DIR__ . '/../..' . '/src/Services/Store/ComposerArtifactService.php', 'EvolutionCMS\\Services\\Store\\InstalledStateService' => __DIR__ . '/../..' . '/src/Services/Store/InstalledStateService.php', 'EvolutionCMS\\Services\\Store\\LegacyDeleteService' => __DIR__ . '/../..' . '/src/Services/Store/LegacyDeleteService.php', 'EvolutionCMS\\Services\\Store\\ModuleActionService' => __DIR__ . '/../..' . '/src/Services/Store/ModuleActionService.php', @@ -2125,6 +2126,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Traits\\Models\\SoftDeletes' => __DIR__ . '/../..' . '/src/Traits/Models/SoftDeletes.php', 'EvolutionCMS\\Traits\\Models\\TimeMutator' => __DIR__ . '/../..' . '/src/Traits/Models/TimeMutator.php', 'EvolutionCMS\\Traits\\Path' => __DIR__ . '/../..' . '/src/Traits/Path.php', + 'EvolutionCMS\\Traits\\RunsComposerShell' => __DIR__ . '/../..' . '/src/Traits/RunsComposerShell.php', 'EvolutionCMS\\Traits\\Settings' => __DIR__ . '/../..' . '/src/Traits/Settings.php', 'EvolutionCMS\\UrlProcessor' => __DIR__ . '/../..' . '/src/UrlProcessor.php', 'EvolutionCMS\\UserManager\\Facades\\UserManager' => __DIR__ . '/..' . '/evolutioncms-services/user-manager/src/Facades/UserManager.php', diff --git a/install/cli-install.php b/install/cli-install.php index 8f65db103b..2d332541a6 100644 --- a/install/cli-install.php +++ b/install/cli-install.php @@ -66,6 +66,13 @@ protected function runComposerUpdate(string $cmd): void if (!empty($out) && is_array($out)) { echo implode(PHP_EOL, $out), PHP_EOL; } + // The archive ships a complete vendor directory, so a failed update (no + // network, say) leaves a working install behind: say so instead of + // reporting nothing. + if ((int) $exitCode !== 0) { + warning('⚠ Composer update failed with exit code ' . (int) $exitCode . '; the dependencies shipped with the archive are kept.'); + warning('⚠ Run "composer update" in the core directory once the server can reach Packagist, or install with --skipComposer=y offline.'); + } } catch (\Exception $e) { info('- No command execution methods available (all disabled).'); warning('⚠ Run "composer update" manually.');