<?php
/**
* @package Grav\Console\Gpm
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Console\Gpm;
use Exception;
use Grav\Common\Grav;
use Grav\Common\Filesystem\Folder;
use Grav\Common\HTTP\Response;
use Grav\Common\GPM\GPM;
use Grav\Common\GPM\Installer;
use Grav\Console\GpmCommand;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use ZipArchive;
use function is_array;
use function is_callable;
/**
* Class DirectInstallCommand
* @package Grav\Console\Gpm
*/
class DirectInstallCommand extends GpmCommand
{
/** @var string */
protected $all_yes;
/** @var string */
protected $destination;
/**
* @return void
*/
protected function configure(): void
{
$this
->setName('direct-install')
->setAliases(['directinstall'])
->addArgument(
'package-file',
InputArgument::REQUIRED,
'Installable package local <path> or remote <URL>. Can install specific version'
)
->addOption(
'all-yes',
'y',
InputOption::VALUE_NONE,
'Assumes yes (or best approach) instead of prompting'
)
->addOption(
'destination',
'd',
InputOption::VALUE_OPTIONAL,
'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',
GRAV_ROOT
)
->setDescription('Installs Grav, plugin, or theme directly from a file or a URL')
->setHelp('The <info>direct-install</info> command installs Grav, plugin, or theme directly from a file or a URL');
}
/**
* @return int
*/
protected function serve(): int
{
$input = $this->getInput();
$io = $this->getIO();
if (!class_exists(ZipArchive::class)) {
$io->title('Direct Install');
$io->error('php-zip extension needs to be enabled!');
return 1;
}
// Making sure the destination is usable
$this->destination = realpath($input->getOption('destination'));
if (!Installer::isGravInstance($this->destination) ||
!Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])
) {
$io->writeln('<red>ERROR</red>: ' . Installer::lastErrorMsg());
return 1;
}
$this->all_yes = $input->getOption('all-yes');
$package_file = $input->getArgument('package-file');
$question = new ConfirmationQuestion("Are you sure you want to direct-install <cyan>{$package_file}</cyan> [y|N] ", false);
$answer = $this->all_yes ? true : $io->askQuestion($question);
if (!$answer) {
$io->writeln('exiting...');
$io->newLine();
return 1;
}
$tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
$tmp_zip = $tmp_dir . uniqid('/Grav-', false);
$io->newLine();
$io->writeln("Preparing to install <cyan>{$package_file}</cyan>");
$zip = null;
if (Response::isRemote($package_file)) {
$io->write(' |- Downloading package... 0%');
try {
$zip = GPM::downloadPackage($package_file, $tmp_zip);
} catch (RuntimeException $e) {
$io->newLine();
$io->writeln(" `- <red>ERROR: {$e->getMessage()}</red>");
$io->newLine();
return 1;
}
if ($zip) {
$io->write("\x0D");
$io->write(' |- Downloading package... 100%');
$io->newLine();
}
} elseif (is_file($package_file)) {
$io->write(' |- Copying package... 0%');
$zip = GPM::copyPackage($package_file, $tmp_zip);
if ($zip) {
$io->write("\x0D");
$io->write(' |- Copying package... 100%');
$io->newLine();
}
}
if ($zip && file_exists($zip)) {
$tmp_source = $tmp_dir . uniqid('/Grav-', false);
$io->write(' |- Extracting package... ');
$extracted = Installer::unZip($zip, $tmp_source);
if (!$extracted) {
$io->write("\x0D");
$io->writeln(' |- Extracting package... <red>failed</red>');
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
$io->write("\x0D");
$io->writeln(' |- Extracting package... <green>ok</green>');
$type = GPM::getPackageType($extracted);
if (!$type) {
$io->writeln(" '- <red>ERROR: Not a valid Grav package</red>");
$io->newLine();
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
$blueprint = GPM::getBlueprints($extracted);
if ($blueprint) {
if (isset($blueprint['dependencies'])) {
$dependencies = [];
foreach ($blueprint['dependencies'] as $dependency) {
if (is_array($dependency)) {
if (isset($dependency['name'])) {
$dependencies[] = $dependency['name'];
}
if (isset($dependency['github'])) {
$dependencies[] = $dependency['github'];
}
} else {
$dependencies[] = $dependency;
}
}
$io->writeln(' |- Dependencies found... <cyan>[' . implode(',', $dependencies) . ']</cyan>');
$question = new ConfirmationQuestion(" | '- Dependencies will not be satisfied. Continue ? [y|N] ", false);
$answer = $this->all_yes ? true : $io->askQuestion($question);
if (!$answer) {
$io->writeln('exiting...');
$io->newLine();
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
}
}
if ($type === 'grav') {
$io->write(' |- Checking destination... ');
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
$io->write("\x0D");
$io->writeln(' |- Checking destination... <yellow>symbolic link</yellow>');
$io->writeln(" '- <red>ERROR: symlinks found...</red> <yellow>" . GRAV_ROOT . '</yellow>');
$io->newLine();
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
$io->write("\x0D");
$io->writeln(' |- Checking destination... <green>ok</green>');
$io->write(' |- Installing package... ');
$this->upgradeGrav($zip, $extracted);
} else {
$name = GPM::getPackageName($extracted);
if (!$name) {
$io->writeln('<red>ERROR: Name could not be determined.</red> Please specify with --name|-n');
$io->newLine();
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
$install_path = GPM::getInstallPath($type, $name);
$is_update = file_exists($install_path);
$io->write(' |- Checking destination... ');
Installer::isValidDestination(GRAV_ROOT . DS . $install_path);
if (Installer::lastErrorCode() === Installer::IS_LINK) {
$io->write("\x0D");
$io->writeln(' |- Checking destination... <yellow>symbolic link</yellow>');
$io->writeln(" '- <red>ERROR: symlink found...</red> <yellow>" . GRAV_ROOT . DS . $install_path . '</yellow>');
$io->newLine();
Folder::delete($tmp_source);
Folder::delete($tmp_zip);
return 1;
}
$io->write("\x0D");
$io->writeln(' |- Checking destination... <green>ok</green>');
$io->write(' |- Installing package... ');
Installer::install(
$zip,
$this->destination,
$options = [
'install_path' => $install_path,
'theme' => (($type === 'theme')),
'is_update' => $is_update
],
$extracted
);
// clear cache after successful upgrade
$this->clearCache();
}
Folder::delete($tmp_source);
$io->write("\x0D");
if (Installer::lastErrorCode()) {
$io->writeln(" '- <red>" . Installer::lastErrorMsg() . '</red>');
$io->newLine();
} else {
$io->writeln(' |- Installing package... <green>ok</green>');
$io->writeln(" '- <green>Success!</green> ");
$io->newLine();
}
} else {
$io->writeln(" '- <red>ERROR: ZIP package could not be found</red>");
Folder::delete($tmp_zip);
return 1;
}
Folder::delete($tmp_zip);
return 0;
}
/**
* @param string $zip
* @param string $folder
* @return void
*/
private function upgradeGrav(string $zip, string $folder): void
{
if (!is_dir($folder)) {
Installer::setError('Invalid source folder');
}
try {
$script = $folder . '/system/install.php';
/** Install $installer */
if ((file_exists($script) && $install = include $script) && is_callable($install)) {
$install($zip);
} else {
throw new RuntimeException('Uploaded archive file is not a valid Grav update package');
}
} catch (Exception $e) {
Installer::setError($e->getMessage());
}
}
}