namespace Cli\Commands;
+use Cli\Services\ComposerLocator;
use Cli\Services\EnvironmentLoader;
use Cli\Services\ProgramRunner;
+use Cli\Services\RequirementsValidator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln("<info>Checking system requirements...</info>");
- $this->ensureRequirementsMet();
+ RequirementsValidator::validate();
$suggestedOutPath = $input->getArgument('target-directory');
$this->cloneBookStackViaGit($installDir);
$output->writeln("<info>Checking composer exists...</info>");
- $composer = $this->getComposerProgram($installDir);
- try {
- $composer->ensureFound();
- } catch (\Exception $exception) {
+ $composerLocator = new ComposerLocator($installDir);
+ $composer = $composerLocator->getProgram();
+ if (!$composer->isFound()) {
$output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
- $this->downloadComposerToInstall($installDir);
+ $composerLocator->download();
}
$output->writeln("<info>Installing application dependencies using composer...</info>");
return Command::SUCCESS;
}
- /**
- * Ensure the required PHP extensions are installed for this command.
- * @throws CommandError
- */
- protected function ensureRequirementsMet(): void
- {
- $errors = [];
-
- if (version_compare(PHP_VERSION, '8.0.2') < 0) {
- $errors[] = "PHP >= 8.0.2 is required to install BookStack.";
- }
-
- $requiredExtensions = ['bcmath', 'curl', 'gd', 'iconv', 'libxml', 'mbstring', 'mysqlnd', 'xml'];
- foreach ($requiredExtensions as $extension) {
- if (!extension_loaded($extension)) {
- $errors[] = "The \"{$extension}\" PHP extension is required by not active.";
- }
- }
-
- try {
- (new ProgramRunner('git', '/usr/bin/git'))->ensureFound();
- (new ProgramRunner('php', '/usr/bin/php'))->ensureFound();
- } catch (\Exception $exception) {
- $errors[] = $exception->getMessage();
- }
-
- if (count($errors) > 0) {
- throw new CommandError("Requirements failed with following errors:\n" . implode("\n", $errors));
- }
- }
-
- protected function downloadComposerToInstall(string $installDir): void
- {
- $setupPath = $installDir . DIRECTORY_SEPARATOR . 'composer-setup.php';
- $signature = file_get_contents('https://composer.github.io/installer.sig');
- copy('https://getcomposer.org/installer', $setupPath);
- $checksum = hash_file('sha384', $setupPath);
-
- if ($signature !== $checksum) {
- unlink($setupPath);
- throw new CommandError("Could not install composer, checksum validation failed.");
- }
-
- $status = (new ProgramRunner('php', '/usr/bin/php'))
- ->runWithoutOutputCallbacks([
- $setupPath, '--quiet',
- "--install-dir={$installDir}",
- "--filename=composer",
- ]);
-
- unlink($setupPath);
-
- if ($status !== 0) {
- throw new CommandError("Could not install composer, composer-setup script run failed.");
- }
- }
-
- /**
- * Get the composer program.
- */
- protected function getComposerProgram(string $installDir): ProgramRunner
- {
- return (new ProgramRunner('composer', '/usr/local/bin/composer'))
- ->withTimeout(300)
- ->withIdleTimeout(15)
- ->withAdditionalPathLocation($installDir);
- }
-
protected function generateAppKey(string $installDir): void
{
$errors = (new ProgramRunner('php', '/usr/bin/php'))
--- /dev/null
+<?php
+
+namespace Cli\Commands;
+
+use Cli\Services\ComposerLocator;
+use Cli\Services\EnvironmentLoader;
+use Cli\Services\ProgramRunner;
+use Cli\Services\RequirementsValidator;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class UpdateCommand extends Command
+{
+
+ public function __construct(
+ protected string $appDir
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this->setName('update');
+ $this->setDescription('Update an existing BookStack instance.');
+ }
+
+ /**
+ * @throws CommandError
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $output->writeln("<info>Checking system requirements...</info>");
+ RequirementsValidator::validate();
+
+ $output->writeln("<info>Checking composer exists...</info>");
+ $composerLocator = new ComposerLocator($this->appDir);
+ $composer = $composerLocator->getProgram();
+ if (!$composer->isFound()) {
+ $output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
+ $composerLocator->download();
+ }
+
+ $output->writeln("<info>Fetching latest code via Git...</info>");
+ $this->updateCodeUsingGit();
+
+ $output->writeln("<info>Installing PHP dependencies via composer...</info>");
+ $this->installComposerDependencies($composer);
+
+ $output->writeln("<info>Running database migrations...</info>");
+ $this->runArtisanCommand(['migrate', '--force']);
+
+ $output->writeln("<info>Clearing app caches...</info>");
+ $this->runArtisanCommand(['cache:clear']);
+ $this->runArtisanCommand(['config:clear']);
+ $this->runArtisanCommand(['view:clear']);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @throws CommandError
+ */
+ protected function updateCodeUsingGit(): void
+ {
+ $errors = (new ProgramRunner('git', '/usr/bin/git'))
+ ->withTimeout(240)
+ ->withIdleTimeout(15)
+ ->runCapturingStdErr([
+ '-C', $this->appDir,
+ 'pull', '-q', 'origin', 'release',
+ ]);
+
+ if ($errors) {
+ throw new CommandError("Failed git pull with errors:\n" . $errors);
+ }
+ }
+
+ /**
+ * @throws CommandError
+ */
+ protected function installComposerDependencies(ProgramRunner $composer): void
+ {
+ $errors = $composer->runCapturingStdErr([
+ 'install',
+ '--no-dev', '-n', '-q', '--no-progress',
+ '-d', $this->appDir,
+ ]);
+
+ if ($errors) {
+ throw new CommandError("Failed composer install with errors:\n" . $errors);
+ }
+ }
+
+ protected function runArtisanCommand(array $commandArgs): void
+ {
+ $errors = (new ProgramRunner('php', '/usr/bin/php'))
+ ->withTimeout(60)
+ ->withIdleTimeout(5)
+ ->withEnvironment(EnvironmentLoader::load($this->appDir))
+ ->runCapturingAllOutput([
+ $this->appDir . DIRECTORY_SEPARATOR . 'artisan',
+ '-n', '-q',
+ ...$commandArgs
+ ]);
+
+ if ($errors) {
+ $cmdString = implode(' ', $commandArgs);
+ throw new CommandError("Failed 'php artisan {$cmdString}' with errors:\n" . $errors);
+ }
+ }
+}
--- /dev/null
+<?php
+
+namespace Cli\Services;
+
+use Exception;
+
+class ComposerLocator
+{
+ public function __construct(
+ protected string $appDir
+ ) {
+ }
+
+ public function getProgram(): ProgramRunner
+ {
+ return (new ProgramRunner('composer', '/usr/local/bin/composer'))
+ ->withTimeout(300)
+ ->withIdleTimeout(15)
+ ->withAdditionalPathLocation($this->appDir);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function download(): void
+ {
+ $setupPath = $this->appDir . DIRECTORY_SEPARATOR . 'composer-setup.php';
+ $signature = file_get_contents('https://composer.github.io/installer.sig');
+ copy('https://getcomposer.org/installer', $setupPath);
+ $checksum = hash_file('sha384', $setupPath);
+
+ if ($signature !== $checksum) {
+ unlink($setupPath);
+ throw new Exception("Could not install composer, checksum validation failed.");
+ }
+
+ $status = (new ProgramRunner('php', '/usr/bin/php'))
+ ->runWithoutOutputCallbacks([
+ $setupPath, '--quiet',
+ "--install-dir={$this->appDir}",
+ "--filename=composer",
+ ]);
+
+ unlink($setupPath);
+
+ if ($status !== 0) {
+ throw new Exception("Could not install composer, composer-setup script run failed.");
+ }
+ }
+}
$this->resolveProgramPath();
}
+ public function isFound(): bool
+ {
+ try {
+ $this->ensureFound();
+ return true;
+ } catch (\Exception $exception) {
+ return false;
+ }
+ }
+
protected function startProcess(array $args): Process
{
$programPath = $this->resolveProgramPath();
--- /dev/null
+<?php
+
+namespace Cli\Services;
+
+use Exception;
+
+class RequirementsValidator
+{
+ /**
+ * Ensure the required PHP extensions are installed for this command.
+ * @throws Exception
+ */
+ public static function validate(): void
+ {
+ $errors = [];
+
+ if (version_compare(PHP_VERSION, '8.0.2') < 0) {
+ $errors[] = "PHP >= 8.0.2 is required to install BookStack.";
+ }
+
+ $requiredExtensions = ['bcmath', 'curl', 'gd', 'iconv', 'libxml', 'mbstring', 'mysqlnd', 'xml'];
+ foreach ($requiredExtensions as $extension) {
+ if (!extension_loaded($extension)) {
+ $errors[] = "The \"{$extension}\" PHP extension is required by not active.";
+ }
+ }
+
+ try {
+ (new ProgramRunner('git', '/usr/bin/git'))->ensureFound();
+ (new ProgramRunner('php', '/usr/bin/php'))->ensureFound();
+ } catch (Exception $exception) {
+ $errors[] = $exception->getMessage();
+ }
+
+ if (count($errors) > 0) {
+ throw new Exception("Requirements failed with following errors:\n" . implode("\n", $errors));
+ }
+ }
+}
\ No newline at end of file
use Symfony\Component\Console\Application;
use Cli\Commands\BackupCommand;
use Cli\Commands\InitCommand;
+use Cli\Commands\UpdateCommand;
// Get the directory of the CLI "entrypoint", adjusted to be the real
// location where running via a phar.
if (str_starts_with($scriptDir, 'phar://')) {
$scriptDir = dirname(Phar::running(false));
}
+// TODO - Add smarter strategy for locating install
+// (working directory or directory of running script or maybe passed option?)
$bsDir = dirname($scriptDir);
// Setup our CLI
$app = new Application('bookstack-system');
$app->add(new BackupCommand($bsDir));
+$app->add(new UpdateCommand($bsDir));
$app->add(new InitCommand());
try {