namespace Cli\Commands;
+use Cli\Services\AppLocator;
use Cli\Services\EnvironmentLoader;
use Cli\Services\ProgramRunner;
use RecursiveDirectoryIterator;
final class BackupCommand extends Command
{
- public function __construct(
- protected string $appDir
- ) {
- parent::__construct();
- }
-
protected function configure(): void
{
$this->setName('backup');
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
+ $appDir = AppLocator::require($input->getOption('app-directory'));
+ $output->writeln("<info>Checking system requirements...</info>");
$this->ensureRequiredExtensionInstalled();
$handleDatabase = !$input->getOption('no-database');
$handleThemes = !$input->getOption('no-themes');
$suggestedOutPath = $input->getArgument('backup-path');
- $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
+ $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
// Create a new ZIP file
$zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
$zip->open($zipTempFile, ZipArchive::CREATE);
// Add default files (.env config file and this CLI)
- $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
- $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
+ $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
+ $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
if ($handleDatabase) {
$output->writeln("<info>Dumping the database via mysqldump...</info>");
- $dumpTempFile = $this->createDatabaseDump();
+ $dumpTempFile = $this->createDatabaseDump($appDir);
$output->writeln("<info>Adding database dump to backup archive...</info>");
$zip->addFile($dumpTempFile, 'db.sql');
}
if ($handleUploads) {
$output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
- $this->addUploadFoldersToZip($zip);
+ $this->addUploadFoldersToZip($zip, $appDir);
}
if ($handleThemes) {
$output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
}
// Close off our zip and move it to the required location
* a path to a folder, or a path to a file in relative or absolute form.
* @throws CommandError
*/
- protected function buildZipFilePath(string $suggestedOutPath): string
+ protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
{
- $zipDir = getcwd() ?: $this->appDir;
+ $zipDir = getcwd() ?: $appDir;
$zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
if ($suggestedOutPath) {
* Add app-relative upload folders to the provided zip archive.
* Will recursively go through all directories to add all files.
*/
- protected function addUploadFoldersToZip(ZipArchive $zip): void
+ protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
{
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
- $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
+ $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
}
/**
* Create a database dump and return the path to the dumped SQL output.
* @throws CommandError
*/
- protected function createDatabaseDump(): string
+ protected function createDatabaseDump(string $appDir): string
{
- $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($this->appDir);
+ $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
$dbOptions = [
'host' => ($envOptions['DB_HOST'] ?? ''),
'username' => ($envOptions['DB_USERNAME'] ?? ''),
namespace Cli\Commands;
+use Cli\Services\AppLocator;
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\Input\InputOption;
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.');
+ $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to update', '');
}
/**
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
+ $appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("<info>Checking system requirements...</info>");
RequirementsValidator::validate();
$output->writeln("<info>Checking composer exists...</info>");
- $composerLocator = new ComposerLocator($this->appDir);
+ $composerLocator = new ComposerLocator($appDir);
$composer = $composerLocator->getProgram();
if (!$composer->isFound()) {
$output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
}
$output->writeln("<info>Fetching latest code via Git...</info>");
- $this->updateCodeUsingGit();
+ $this->updateCodeUsingGit($appDir);
$output->writeln("<info>Installing PHP dependencies via composer...</info>");
- $this->installComposerDependencies($composer);
+ $this->installComposerDependencies($composer, $appDir);
$output->writeln("<info>Running database migrations...</info>");
- $this->runArtisanCommand(['migrate', '--force']);
+ $this->runArtisanCommand(['migrate', '--force'], $appDir);
$output->writeln("<info>Clearing app caches...</info>");
- $this->runArtisanCommand(['cache:clear']);
- $this->runArtisanCommand(['config:clear']);
- $this->runArtisanCommand(['view:clear']);
+ $this->runArtisanCommand(['cache:clear'], $appDir);
+ $this->runArtisanCommand(['config:clear'], $appDir);
+ $this->runArtisanCommand(['view:clear'], $appDir);
return Command::SUCCESS;
}
/**
* @throws CommandError
*/
- protected function updateCodeUsingGit(): void
+ protected function updateCodeUsingGit(string $appDir): void
{
$errors = (new ProgramRunner('git', '/usr/bin/git'))
->withTimeout(240)
->withIdleTimeout(15)
->runCapturingStdErr([
- '-C', $this->appDir,
+ '-C', $appDir,
'pull', '-q', 'origin', 'release',
]);
/**
* @throws CommandError
*/
- protected function installComposerDependencies(ProgramRunner $composer): void
+ protected function installComposerDependencies(ProgramRunner $composer, string $appDir): void
{
$errors = $composer->runCapturingStdErr([
'install',
'--no-dev', '-n', '-q', '--no-progress',
- '-d', $this->appDir,
+ '-d', $appDir,
]);
if ($errors) {
}
}
- protected function runArtisanCommand(array $commandArgs): void
+ protected function runArtisanCommand(array $commandArgs, string $appDir): void
{
$errors = (new ProgramRunner('php', '/usr/bin/php'))
->withTimeout(60)
->withIdleTimeout(5)
- ->withEnvironment(EnvironmentLoader::load($this->appDir))
+ ->withEnvironment(EnvironmentLoader::load($appDir))
->runCapturingAllOutput([
- $this->appDir . DIRECTORY_SEPARATOR . 'artisan',
+ $appDir . DIRECTORY_SEPARATOR . 'artisan',
'-n', '-q',
...$commandArgs
]);
--- /dev/null
+<?php
+
+namespace Cli\Services;
+
+use Phar;
+
+class AppLocator
+{
+ public static function search(string $directory = ''): string
+ {
+ $directoriesToSearch = $directory ? [$directory] : [
+ getcwd(),
+ static::getCliDirectory(),
+ ];
+
+ foreach ($directoriesToSearch as $directory) {
+ if ($directory && static::isProbablyAppDirectory($directory)) {
+ return $directory;
+ }
+ }
+
+ return '';
+ }
+
+ public static function require(string $directory = ''): string
+ {
+ $dir = static::search($directory);
+
+ if (!$dir) {
+ throw new \Exception('Could not find a valid BookStack installation');
+ }
+
+ return $dir;
+ }
+
+ protected static function getCliDirectory(): string
+ {
+ $scriptDir = dirname(__DIR__);
+ if (str_starts_with($scriptDir, 'phar://')) {
+ $scriptDir = dirname(Phar::running(false));
+ }
+
+ return dirname($scriptDir);
+ }
+
+ protected static function isProbablyAppDirectory(string $directory): bool
+ {
+ return file_exists($directory . DIRECTORY_SEPARATOR . 'version')
+ && file_exists($directory . DIRECTORY_SEPARATOR . 'package.json');
+ }
+}
{
$output = '';
$callable = function ($data) use (&$output) {
- $output .= $data . "\n";
+ $output .= $data;
};
$this->runWithoutOutputCallbacks($args, $callable, $callable);
{
$err = '';
$this->runWithoutOutputCallbacks($args, fn() => '', function ($data) use (&$err) {
- $err .= $data . "\n";
+ $err .= $data;
});
return $err;
}
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.
-$scriptDir = __DIR__;
-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);
+use Symfony\Component\Console\Formatter\OutputFormatterStyle;
+use Symfony\Component\Console\Output\ConsoleOutput;
// Setup our CLI
$app = new Application('bookstack-system');
+$app->setCatchExceptions(false);
-$app->add(new BackupCommand($bsDir));
-$app->add(new UpdateCommand($bsDir));
+$app->add(new BackupCommand());
+$app->add(new UpdateCommand());
$app->add(new InitCommand());
try {
$app->run();
} catch (Exception $error) {
- fwrite(STDERR, "An error occurred when attempting to run a command:\n");
- fwrite(STDERR, $error->getMessage() . "\n");
+ $output = (new ConsoleOutput())->getErrorOutput();
+ $output->getFormatter()->setStyle('error', new OutputFormatterStyle('red'));
+ $output->writeln("<error>\nAn error occurred when attempting to run a command:\n</error>");
+ $output->writeln($error->getMessage());
exit(1);
}