Compare commits
16 commits
developmen
...
admin-cli
Author | SHA1 | Date | |
---|---|---|---|
|
c84d8aa4c1 | ||
|
0c14f22831 | ||
|
74b5fadf60 | ||
|
2894ce275c | ||
|
6bfbad2393 | ||
|
3b6de8872a | ||
|
0be1bf7499 | ||
|
4d9d591792 | ||
|
21db0ebf46 | ||
|
cbf77ecdbf | ||
|
3cc761d2d8 | ||
|
ff0c183a66 | ||
|
ead5ebbee1 | ||
|
6b9732588c | ||
|
f77d4ce295 | ||
|
6d055e6f72 |
20 changed files with 2329 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -26,3 +26,4 @@ webpack-stats.json
|
|||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
/composer
|
2
scripts/.gitignore
vendored
Normal file
2
scripts/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.phar
|
||||
vendor/
|
176
scripts/Commands/BackupCommand.php
Normal file
176
scripts/Commands/BackupCommand.php
Normal file
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Commands;
|
||||
|
||||
use Cli\Services\AppLocator;
|
||||
use Cli\Services\EnvironmentLoader;
|
||||
use Cli\Services\MySqlRunner;
|
||||
use RecursiveDirectoryIterator;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use ZipArchive;
|
||||
|
||||
final class BackupCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('backup');
|
||||
$this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
|
||||
$this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
|
||||
$this->addOption('no-database', null, null, "Skip adding a database dump to the backup");
|
||||
$this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup");
|
||||
$this->addOption('no-themes', null, null, "Skip adding the themes folder to the backup");
|
||||
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
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');
|
||||
$handleUploads = !$input->getOption('no-uploads');
|
||||
$handleThemes = !$input->getOption('no-themes');
|
||||
$suggestedOutPath = $input->getArgument('backup-path');
|
||||
|
||||
$zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
|
||||
|
||||
// Create a new ZIP file
|
||||
$zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
|
||||
$dumpTempFile = '';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipTempFile, ZipArchive::CREATE);
|
||||
|
||||
// Add default files (.env config file and this CLI)
|
||||
$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($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, $appDir);
|
||||
}
|
||||
|
||||
if ($handleThemes) {
|
||||
$output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
|
||||
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
|
||||
}
|
||||
|
||||
// Close off our zip and move it to the required location
|
||||
$zip->close();
|
||||
// Delete our temporary DB dump file if exists. Must be done after zip close.
|
||||
if ($dumpTempFile) {
|
||||
unlink($dumpTempFile);
|
||||
}
|
||||
// Move the zip into the target location
|
||||
rename($zipTempFile, $zipOutFile);
|
||||
|
||||
// Announce end
|
||||
$output->writeln("<info>Backup finished.</info>");
|
||||
$output->writeln("Output ZIP saved to: {$zipOutFile}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the required PHP extensions are installed for this command.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function ensureRequiredExtensionInstalled(): void
|
||||
{
|
||||
if (!extension_loaded('zip')) {
|
||||
throw new CommandError('The "zip" PHP extension is required to run this command');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full zip path from the given suggestion, which may be empty,
|
||||
* a path to a folder, or a path to a file in relative or absolute form.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
|
||||
{
|
||||
$zipDir = getcwd() ?: $appDir;
|
||||
$zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
|
||||
|
||||
if ($suggestedOutPath) {
|
||||
if (is_dir($suggestedOutPath)) {
|
||||
$zipDir = realpath($suggestedOutPath);
|
||||
} else if (is_dir(dirname($suggestedOutPath))) {
|
||||
$zipDir = realpath(dirname($suggestedOutPath));
|
||||
$zipName = basename($suggestedOutPath);
|
||||
} else {
|
||||
throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
|
||||
}
|
||||
}
|
||||
|
||||
$fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
|
||||
|
||||
if (file_exists($fullPath)) {
|
||||
throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
|
||||
}
|
||||
|
||||
return $fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, string $appDir): void
|
||||
{
|
||||
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
|
||||
$this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add all contents of the given dirPath to the provided zip file
|
||||
* with a zip location of the targetZipPath.
|
||||
*/
|
||||
protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
|
||||
{
|
||||
$dirIter = new RecursiveDirectoryIterator($dirPath);
|
||||
$fileIter = new \RecursiveIteratorIterator($dirIter);
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($fileIter as $file) {
|
||||
if (!$file->isDir()) {
|
||||
$zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database dump and return the path to the dumped SQL output.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function createDatabaseDump(string $appDir): string
|
||||
{
|
||||
$envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
|
||||
$mysql = MySqlRunner::fromEnvOptions($envOptions);
|
||||
$mysql->ensureOptionsSet();
|
||||
|
||||
$dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
|
||||
try {
|
||||
$mysql->runDumpToFile($dumpTempFile);
|
||||
} catch (\Exception $exception) {
|
||||
unlink($dumpTempFile);
|
||||
throw new CommandError($exception->getMessage());
|
||||
}
|
||||
|
||||
return $dumpTempFile;
|
||||
}
|
||||
}
|
5
scripts/Commands/CommandError.php
Normal file
5
scripts/Commands/CommandError.php
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Commands;
|
||||
|
||||
class CommandError extends \Exception {}
|
173
scripts/Commands/InitCommand.php
Normal file
173
scripts/Commands/InitCommand.php
Normal file
|
@ -0,0 +1,173 @@
|
|||
<?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\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class InitCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('init');
|
||||
$this->setDescription('Initialise a new BookStack install. Does not configure the webserver or database.');
|
||||
$this->addArgument('target-directory', InputArgument::OPTIONAL, 'The directory to create the BookStack install within. Must be empty.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln("<info>Checking system requirements...</info>");
|
||||
RequirementsValidator::validate();
|
||||
|
||||
$suggestedOutPath = $input->getArgument('target-directory');
|
||||
|
||||
$output->writeln("<info>Locating and checking install directory...</info>");
|
||||
$installDir = $this->getInstallDir($suggestedOutPath);
|
||||
$this->ensureInstallDirEmptyAndWritable($installDir);
|
||||
|
||||
$output->writeln("<info>Cloning down BookStack project to install directory...</info>");
|
||||
$this->cloneBookStackViaGit($installDir);
|
||||
|
||||
$output->writeln("<info>Checking composer exists...</info>");
|
||||
$composerLocator = new ComposerLocator($installDir);
|
||||
$composer = $composerLocator->getProgram();
|
||||
if (!$composer->isFound()) {
|
||||
$output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
|
||||
$composerLocator->download();
|
||||
}
|
||||
|
||||
$output->writeln("<info>Installing application dependencies using composer...</info>");
|
||||
$this->installComposerDependencies($composer, $installDir);
|
||||
|
||||
$output->writeln("<info>Creating .env file from .env.example...</info>");
|
||||
copy($installDir . DIRECTORY_SEPARATOR . '.env.example', $installDir . DIRECTORY_SEPARATOR . '.env');
|
||||
sleep(1);
|
||||
|
||||
$output->writeln("<info>Generating app key...</info>");
|
||||
$this->generateAppKey($installDir);
|
||||
|
||||
// Announce end
|
||||
$output->writeln("<info>A BookStack install has been initialized at: {$installDir}\n</info>");
|
||||
$output->writeln("<info>You will still need to:</info>");
|
||||
$output->writeln("<info>- Update the .env file in the install with correct URL, database and email details.</info>");
|
||||
$output->writeln("<info>- Run 'php artisan migrate' to set-up the database.</info>");
|
||||
$output->writeln("<info>- Configure your webserver for use with BookStack.</info>");
|
||||
$output->writeln("<info>- Ensure the required directories (storage/ bootstrap/cache public/uploads) are web-server writable.</info>");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function generateAppKey(string $installDir): void
|
||||
{
|
||||
$errors = (new ProgramRunner('php', '/usr/bin/php'))
|
||||
->withTimeout(60)
|
||||
->withIdleTimeout(5)
|
||||
->withEnvironment(EnvironmentLoader::load($installDir))
|
||||
->runCapturingAllOutput([
|
||||
$installDir . DIRECTORY_SEPARATOR . 'artisan',
|
||||
'key:generate', '--force', '-n', '-q'
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
throw new CommandError("Failed 'php artisan key:generate' with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run composer install to download PHP dependencies.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function installComposerDependencies(ProgramRunner $composer, string $installDir): void
|
||||
{
|
||||
$errors = $composer->runCapturingStdErr([
|
||||
'install',
|
||||
'--no-dev', '-n', '-q', '--no-progress',
|
||||
'-d', $installDir
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
throw new CommandError("Failed composer install with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a new instance of BookStack to the given install folder.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function cloneBookStackViaGit(string $installDir): void
|
||||
{
|
||||
$git = (new ProgramRunner('git', '/usr/bin/git'))
|
||||
->withTimeout(240)
|
||||
->withIdleTimeout(15);
|
||||
|
||||
$errors = $git->runCapturingStdErr([
|
||||
'clone', '-q',
|
||||
'--branch', 'release',
|
||||
'--single-branch',
|
||||
'https://github.com/BookStackApp/BookStack.git',
|
||||
$installDir
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
throw new CommandError("Failed git clone with errors:\n" . $errors);
|
||||
}
|
||||
|
||||
// Disable file permission tracking for git repo
|
||||
$git->runCapturingStdErr([
|
||||
'-C', $installDir,
|
||||
'config', 'core.fileMode', 'false'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the installation directory is completely empty to avoid potential conflicts or issues.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function ensureInstallDirEmptyAndWritable(string $installDir): void
|
||||
{
|
||||
$contents = array_diff(scandir($installDir), ['..', '.']);
|
||||
if (count($contents) > 0) {
|
||||
throw new CommandError("Expected install directory to be empty but existing files found in [{$installDir}] target location.");
|
||||
}
|
||||
|
||||
if (!is_writable($installDir)) {
|
||||
throw new CommandError("Target install directory [{$installDir}] is not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full path to the intended location for the BookStack install.
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function getInstallDir(string $suggestedDir): string
|
||||
{
|
||||
$dir = getcwd();
|
||||
|
||||
if ($suggestedDir) {
|
||||
if (is_file($suggestedDir)) {
|
||||
throw new CommandError("Was provided [{$suggestedDir}] as an install path but existing file provided.");
|
||||
} else if (is_dir($suggestedDir)) {
|
||||
$dir = realpath($suggestedDir);
|
||||
} else if (is_dir(dirname($suggestedDir))) {
|
||||
$created = mkdir($suggestedDir);
|
||||
if (!$created) {
|
||||
throw new CommandError("Could not create directory [{$suggestedDir}] for install.");
|
||||
}
|
||||
$dir = realpath($suggestedDir);
|
||||
} else {
|
||||
throw new CommandError("Could not resolve provided [{$suggestedDir}] path to an existing folder.");
|
||||
}
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
}
|
204
scripts/Commands/RestoreCommand.php
Normal file
204
scripts/Commands/RestoreCommand.php
Normal file
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Commands;
|
||||
|
||||
use Cli\Services\AppLocator;
|
||||
use Cli\Services\ArtisanRunner;
|
||||
use Cli\Services\BackupZip;
|
||||
use Cli\Services\EnvironmentLoader;
|
||||
use Cli\Services\InteractiveConsole;
|
||||
use Cli\Services\MySqlRunner;
|
||||
use Cli\Services\ProgramRunner;
|
||||
use Cli\Services\RequirementsValidator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class RestoreCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('restore');
|
||||
$this->addArgument('backup-zip', InputArgument::REQUIRED, 'Path to the ZIP file containing your backup.');
|
||||
$this->setDescription('Restore data and files from a backup ZIP file.');
|
||||
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to restore into', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
|
||||
|
||||
$output->writeln("<info>Warning!</info>");
|
||||
$output->writeln("<info>- A restore operation will overwrite and remove files & content from an existing instance.</info>");
|
||||
$output->writeln("<info>- Any existing tables within the configured database will be dropped.</info>");
|
||||
$output->writeln("<info>- You should only restore into an instance of the same or newer BookStack version.</info>");
|
||||
$output->writeln("<info>- This command won't handle, restore or address any server configuration.</info>");
|
||||
|
||||
$appDir = AppLocator::require($input->getOption('app-directory'));
|
||||
$output->writeln("<info>Checking system requirements...</info>");
|
||||
RequirementsValidator::validate();
|
||||
(new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
|
||||
|
||||
$zipPath = realpath($input->getArgument('backup-zip'));
|
||||
$zip = new BackupZip($zipPath);
|
||||
$contents = $zip->getContentsOverview();
|
||||
|
||||
$output->writeln("\n<info>Contents found in the backup ZIP:</info>");
|
||||
$hasContent = false;
|
||||
foreach ($contents as $info) {
|
||||
$output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
|
||||
if ($info['exists']) {
|
||||
$hasContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasContent) {
|
||||
throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restore-able content.");
|
||||
}
|
||||
|
||||
$output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
|
||||
$output->writeln("<info>Existing content may be overwritten.</info>");
|
||||
|
||||
if (!$interactions->confirm("Do you want to continue?")) {
|
||||
$output->writeln("<info>Stopping restore operation.</info>");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln("<info>Extracting ZIP into temporary directory...</info>");
|
||||
$extractDir = $appDir . DIRECTORY_SEPARATOR . 'restore-temp-' . time();
|
||||
if (!mkdir($extractDir)) {
|
||||
throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
|
||||
}
|
||||
$zip->extractInto($extractDir);
|
||||
|
||||
$envChanges = [];
|
||||
if ($contents['env']['exists']) {
|
||||
$output->writeln("<info>Restoring and merging .env file...</info>");
|
||||
$envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
|
||||
}
|
||||
|
||||
$folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
|
||||
foreach ($folderLocations as $folderSubPath) {
|
||||
if ($contents[$folderSubPath]['exists']) {
|
||||
$output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
|
||||
$this->restoreFolder($folderSubPath, $appDir, $extractDir);
|
||||
}
|
||||
}
|
||||
|
||||
$artisan = (new ArtisanRunner($appDir));
|
||||
if ($contents['db']['exists']) {
|
||||
$output->writeln("<info>Restoring database from SQL dump...</info>");
|
||||
$this->restoreDatabase($appDir, $extractDir);
|
||||
|
||||
$output->writeln("<info>Running database migrations...</info>");
|
||||
$artisan->run(['migrate', '--force']);
|
||||
}
|
||||
|
||||
if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
|
||||
$output->writeln("<info>App URL change made, Updating database with URL change...</info>");
|
||||
$artisan->run([
|
||||
'bookstack:update-url',
|
||||
$envChanges['old_url'], $envChanges['new_url'],
|
||||
]);
|
||||
}
|
||||
|
||||
$output->writeln("<info>Clearing app caches...</info>");
|
||||
$artisan->run(['cache:clear']);
|
||||
$artisan->run(['config:clear']);
|
||||
$artisan->run(['view:clear']);
|
||||
|
||||
$output->writeln("<info>Cleaning up extract directory...</info>");
|
||||
$this->deleteDirectoryAndContents($extractDir);
|
||||
|
||||
$output->writeln("<info>\nRestore operation complete!</info>");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
|
||||
{
|
||||
$oldEnv = EnvironmentLoader::load($extractDir);
|
||||
$currentEnv = EnvironmentLoader::load($appDir);
|
||||
$envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env');
|
||||
$appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env';
|
||||
|
||||
$mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
|
||||
$mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
|
||||
if (!$mysqlOld->testConnection()) {
|
||||
$currentWorking = $mysqlCurrent->testConnection();
|
||||
if (!$currentWorking) {
|
||||
throw new CommandError("Could not find a working database configuration");
|
||||
}
|
||||
|
||||
// Copy across new env details to old env
|
||||
$currentEnvContents = file_get_contents($appEnvPath);
|
||||
$currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
|
||||
return str_starts_with($line, 'DB_');
|
||||
}));
|
||||
$oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
|
||||
return !str_starts_with($line, 'DB_');
|
||||
}));
|
||||
$envContents = implode("\n", [
|
||||
'# Database credentials merged from existing .env file',
|
||||
...$currentEnvDbLines,
|
||||
...$oldEnvLines
|
||||
]);
|
||||
copy($appEnvPath, $appEnvPath . '.backup');
|
||||
}
|
||||
|
||||
$oldUrl = $oldEnv['APP_URL'] ?? '';
|
||||
$newUrl = $currentEnv['APP_URL'] ?? '';
|
||||
$returnData = [
|
||||
'old_url' => $oldUrl,
|
||||
'new_url' => $oldUrl,
|
||||
];
|
||||
|
||||
if ($oldUrl !== $newUrl) {
|
||||
$output->writeln("Found different APP_URL values:");
|
||||
$changedUrl = $interactions->choice('Which would you like to use?', array_filter([$oldUrl, $newUrl]));
|
||||
$envContents = preg_replace('/^APP_URL=.*?$/', 'APP_URL="' . $changedUrl . '"', $envContents);
|
||||
$returnData['new_url'] = $changedUrl;
|
||||
}
|
||||
|
||||
file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
|
||||
|
||||
return $returnData;
|
||||
}
|
||||
|
||||
protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
|
||||
{
|
||||
$fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath;
|
||||
$this->deleteDirectoryAndContents($fullAppFolderPath);
|
||||
rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath);
|
||||
}
|
||||
|
||||
protected function deleteDirectoryAndContents(string $dir)
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($files as $fileinfo) {
|
||||
$path = $fileinfo->getRealPath();
|
||||
$fileinfo->isDir() ? rmdir($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
protected function restoreDatabase(string $appDir, string $extractDir): void
|
||||
{
|
||||
$dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql';
|
||||
$currentEnv = EnvironmentLoader::load($appDir);
|
||||
$mysql = MySqlRunner::fromEnvOptions($currentEnv);
|
||||
$mysql->importSqlFile($dbDump);
|
||||
}
|
||||
}
|
92
scripts/Commands/UpdateCommand.php
Normal file
92
scripts/Commands/UpdateCommand.php
Normal file
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Commands;
|
||||
|
||||
use Cli\Services\AppLocator;
|
||||
use Cli\Services\ArtisanRunner;
|
||||
use Cli\Services\ComposerLocator;
|
||||
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
|
||||
{
|
||||
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', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
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($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($appDir);
|
||||
|
||||
$output->writeln("<info>Installing PHP dependencies via composer...</info>");
|
||||
$this->installComposerDependencies($composer, $appDir);
|
||||
|
||||
$output->writeln("<info>Running database migrations...</info>");
|
||||
$artisan = (new ArtisanRunner($appDir));
|
||||
$artisan->run(['migrate', '--force']);
|
||||
|
||||
$output->writeln("<info>Clearing app caches...</info>");
|
||||
$artisan->run(['cache:clear']);
|
||||
$artisan->run(['config:clear']);
|
||||
$artisan->run(['view:clear']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function updateCodeUsingGit(string $appDir): void
|
||||
{
|
||||
$errors = (new ProgramRunner('git', '/usr/bin/git'))
|
||||
->withTimeout(240)
|
||||
->withIdleTimeout(15)
|
||||
->runCapturingStdErr([
|
||||
'-C', $appDir,
|
||||
'pull', '-q', 'origin', 'release',
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
throw new CommandError("Failed git pull with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandError
|
||||
*/
|
||||
protected function installComposerDependencies(ProgramRunner $composer, string $appDir): void
|
||||
{
|
||||
$errors = $composer->runCapturingStdErr([
|
||||
'install',
|
||||
'--no-dev', '-n', '-q', '--no-progress',
|
||||
'-d', $appDir,
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
throw new CommandError("Failed composer install with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
}
|
51
scripts/Services/AppLocator.php
Normal file
51
scripts/Services/AppLocator.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
31
scripts/Services/ArtisanRunner.php
Normal file
31
scripts/Services/ArtisanRunner.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ArtisanRunner
|
||||
{
|
||||
public function __construct(
|
||||
protected string $appDir
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(array $commandArgs)
|
||||
{
|
||||
$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 Exception("Failed 'php artisan {$cmdString}' with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
}
|
68
scripts/Services/BackupZip.php
Normal file
68
scripts/Services/BackupZip.php
Normal file
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
class BackupZip
|
||||
{
|
||||
protected ZipArchive $zip;
|
||||
public function __construct(
|
||||
protected string $filePath
|
||||
) {
|
||||
$this->zip = new ZipArchive();
|
||||
$status = $this->zip->open($this->filePath);
|
||||
|
||||
if (!file_exists($this->filePath) || $status !== true) {
|
||||
throw new \Exception("Could not open file [{$this->filePath}] as ZIP");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{desc: string, exists: bool}>
|
||||
*/
|
||||
public function getContentsOverview(): array
|
||||
{
|
||||
return [
|
||||
'env' => [
|
||||
'desc' => '.env Config File',
|
||||
'exists' => boolval($this->zip->statName('.env')),
|
||||
],
|
||||
'themes' => [
|
||||
'desc' => 'Themes Folder',
|
||||
'exists' => $this->hasFolder('themes/'),
|
||||
],
|
||||
'public/uploads' => [
|
||||
'desc' => 'Public File Uploads',
|
||||
'exists' => $this->hasFolder('public/uploads/'),
|
||||
],
|
||||
'storage/uploads' => [
|
||||
'desc' => 'Private File Uploads',
|
||||
'exists' => $this->hasFolder('storage/uploads/'),
|
||||
],
|
||||
'db' => [
|
||||
'desc' => 'Database Dump',
|
||||
'exists' => boolval($this->zip->statName('db.sql')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function extractInto(string $directoryPath): void
|
||||
{
|
||||
$result = $this->zip->extractTo($directoryPath);
|
||||
if (!$result) {
|
||||
throw new \Exception("Failed extraction of ZIP into [{$directoryPath}].");
|
||||
}
|
||||
}
|
||||
|
||||
protected function hasFolder($folderPath): bool
|
||||
{
|
||||
for ($i = 0; $i < $this->zip->numFiles; $i++) {
|
||||
$filePath = $this->zip->getNameIndex($i);
|
||||
if (str_starts_with($filePath, $folderPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
50
scripts/Services/ComposerLocator.php
Normal file
50
scripts/Services/ComposerLocator.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?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.");
|
||||
}
|
||||
}
|
||||
}
|
23
scripts/Services/EnvironmentLoader.php
Normal file
23
scripts/Services/EnvironmentLoader.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
class EnvironmentLoader
|
||||
{
|
||||
public static function load(string $rootPath): array
|
||||
{
|
||||
$dotenv = Dotenv::createArrayBacked($rootPath);
|
||||
return $dotenv->safeLoad();
|
||||
}
|
||||
|
||||
public static function loadMergedWithCurrentEnv(string $rootPath): array
|
||||
{
|
||||
$env = static::load($rootPath);
|
||||
foreach ($_SERVER as $key => $val) {
|
||||
$env[$key] = $val;
|
||||
}
|
||||
return $env;
|
||||
}
|
||||
}
|
31
scripts/Services/InteractiveConsole.php
Normal file
31
scripts/Services/InteractiveConsole.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use Symfony\Component\Console\Question\ChoiceQuestion;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
|
||||
class InteractiveConsole
|
||||
{
|
||||
public function __construct(
|
||||
protected QuestionHelper $helper,
|
||||
protected InputInterface $input,
|
||||
protected OutputInterface $output,
|
||||
) {
|
||||
}
|
||||
|
||||
public function confirm(string $text): bool
|
||||
{
|
||||
$question = new ConfirmationQuestion($text . " (y/n)\n", false);
|
||||
return $this->helper->ask($this->input, $this->output, $question);
|
||||
}
|
||||
|
||||
public function choice(string $question, array $answers)
|
||||
{
|
||||
$question = new ChoiceQuestion($question, $answers, $answers[0]);
|
||||
return $this->helper->ask($this->input, $this->output, $question);
|
||||
}
|
||||
}
|
120
scripts/Services/MySqlRunner.php
Normal file
120
scripts/Services/MySqlRunner.php
Normal file
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use Exception;
|
||||
|
||||
class MySqlRunner
|
||||
{
|
||||
public function __construct(
|
||||
protected string $host,
|
||||
protected string $user,
|
||||
protected string $password,
|
||||
protected string $database,
|
||||
protected int $port = 3306
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function ensureOptionsSet(): void
|
||||
{
|
||||
$options = ['host', 'user', 'password', 'database'];
|
||||
foreach ($options as $option) {
|
||||
if (!$this->$option) {
|
||||
throw new Exception("Could not find a valid value for the \"{$option}\" database option.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$output = (new ProgramRunner('mysql', '/usr/bin/mysql'))
|
||||
->withTimeout(240)
|
||||
->withIdleTimeout(5)
|
||||
->runCapturingStdErr([
|
||||
'-h', $this->host,
|
||||
'-P', $this->port,
|
||||
'-u', $this->user,
|
||||
'-p' . $this->password,
|
||||
$this->database,
|
||||
'-e', "show tables;"
|
||||
]);
|
||||
|
||||
return !$output;
|
||||
}
|
||||
|
||||
public function importSqlFile(string $sqlFilePath): void
|
||||
{
|
||||
$output = (new ProgramRunner('mysql', '/usr/bin/mysql'))
|
||||
->withTimeout(240)
|
||||
->withIdleTimeout(5)
|
||||
->runCapturingStdErr([
|
||||
'-h', $this->host,
|
||||
'-P', $this->port,
|
||||
'-u', $this->user,
|
||||
'-p' . $this->password,
|
||||
$this->database,
|
||||
'<', $sqlFilePath
|
||||
]);
|
||||
|
||||
if ($output) {
|
||||
throw new Exception("Failed mysql file import with errors:\n" . $output);
|
||||
}
|
||||
}
|
||||
|
||||
public function runDumpToFile(string $filePath): void
|
||||
{
|
||||
$file = fopen($filePath, 'w');
|
||||
$errors = "";
|
||||
$hasOutput = false;
|
||||
|
||||
try {
|
||||
(new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
|
||||
->withTimeout(240)
|
||||
->withIdleTimeout(15)
|
||||
->runWithoutOutputCallbacks([
|
||||
'-h', $this->host,
|
||||
'-P', $this->port,
|
||||
'-u', $this->user,
|
||||
'-p' . $this->password,
|
||||
'--single-transaction',
|
||||
'--no-tablespaces',
|
||||
$this->database,
|
||||
], function ($data) use (&$file, &$hasOutput) {
|
||||
fwrite($file, $data);
|
||||
$hasOutput = true;
|
||||
}, function ($error) use (&$errors) {
|
||||
$errors .= $error . "\n";
|
||||
});
|
||||
} catch (\Exception $exception) {
|
||||
fclose($file);
|
||||
if ($exception instanceof ProcessTimedOutException) {
|
||||
if (!$hasOutput) {
|
||||
throw new Exception("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
|
||||
} else {
|
||||
throw new Exception("mysqldump operation timed-out after data was received.");
|
||||
}
|
||||
}
|
||||
throw new Exception($exception->getMessage());
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
|
||||
if ($errors) {
|
||||
throw new Exception("Failed mysqldump with errors:\n" . $errors);
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromEnvOptions(array $env): static
|
||||
{
|
||||
$host = ($env['DB_HOST'] ?? '');
|
||||
$username = ($env['DB_USERNAME'] ?? '');
|
||||
$password = ($env['DB_PASSWORD'] ?? '');
|
||||
$database = ($env['DB_DATABASE'] ?? '');
|
||||
$port = intval($env['DB_PORT'] ?? 3306);
|
||||
|
||||
return new static($host, $username, $password, $database, $port);
|
||||
}
|
||||
}
|
122
scripts/Services/ProgramRunner.php
Normal file
122
scripts/Services/ProgramRunner.php
Normal file
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace Cli\Services;
|
||||
|
||||
use Symfony\Component\Process\ExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class ProgramRunner
|
||||
{
|
||||
protected int $timeout = 240;
|
||||
protected int $idleTimeout = 15;
|
||||
protected array $environment = [];
|
||||
protected array $additionalProgramDirectories = [];
|
||||
|
||||
public function __construct(
|
||||
protected string $program,
|
||||
protected string $defaultPath
|
||||
) {
|
||||
}
|
||||
|
||||
public function withTimeout(int $timeoutSeconds): static
|
||||
{
|
||||
$this->timeout = $timeoutSeconds;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withIdleTimeout(int $idleTimeoutSeconds): static
|
||||
{
|
||||
$this->idleTimeout = $idleTimeoutSeconds;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withEnvironment(array $environment): static
|
||||
{
|
||||
$this->environment = $environment;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withAdditionalPathLocation(string $directoryPath): static
|
||||
{
|
||||
$this->additionalProgramDirectories[] = $directoryPath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function runCapturingAllOutput(array $args): string
|
||||
{
|
||||
$output = '';
|
||||
$callable = function ($data) use (&$output) {
|
||||
$output .= $data;
|
||||
};
|
||||
|
||||
$this->runWithoutOutputCallbacks($args, $callable, $callable);
|
||||
return $output;
|
||||
}
|
||||
|
||||
public function runCapturingStdErr(array $args): string
|
||||
{
|
||||
$err = '';
|
||||
$this->runWithoutOutputCallbacks($args, fn() => '', function ($data) use (&$err) {
|
||||
$err .= $data;
|
||||
});
|
||||
return $err;
|
||||
}
|
||||
|
||||
public function runWithoutOutputCallbacks(array $args, callable $stdOutCallback = null, callable $stdErrCallback = null): int
|
||||
{
|
||||
$process = $this->startProcess($args);
|
||||
foreach ($process as $type => $data) {
|
||||
if ($type === $process::ERR) {
|
||||
if ($stdErrCallback) {
|
||||
$stdErrCallback($data);
|
||||
}
|
||||
} else {
|
||||
if ($stdOutCallback) {
|
||||
$stdOutCallback($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $process->getExitCode() ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function ensureFound(): void
|
||||
{
|
||||
$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();
|
||||
$process = new Process([$programPath, ...$args], null, $this->environment);
|
||||
$process->setTimeout($this->timeout);
|
||||
$process->setIdleTimeout($this->idleTimeout);
|
||||
$process->start();
|
||||
return $process;
|
||||
}
|
||||
|
||||
protected function resolveProgramPath(): string
|
||||
{
|
||||
$executableFinder = new ExecutableFinder();
|
||||
$path = $executableFinder->find($this->program, $this->defaultPath, $this->additionalProgramDirectories);
|
||||
|
||||
if (is_null($path) || !is_file($path)) {
|
||||
throw new \Exception("Could not locate \"{$this->program}\" program.");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
39
scripts/Services/RequirementsValidator.php
Normal file
39
scripts/Services/RequirementsValidator.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
}
|
52
scripts/compile.php
Normal file
52
scripts/compile.php
Normal file
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* This file builds a phar archive to contain our CLI code.
|
||||
* Attribution to https://blog.programster.org/creating-phar-files
|
||||
* for the code in this file.
|
||||
*/
|
||||
|
||||
try {
|
||||
$pharFile = 'app.phar';
|
||||
|
||||
// clean up
|
||||
if (file_exists($pharFile)) {
|
||||
unlink($pharFile);
|
||||
}
|
||||
|
||||
if (file_exists($pharFile . '.gz')) {
|
||||
unlink($pharFile . '.gz');
|
||||
}
|
||||
|
||||
// create phar
|
||||
$phar = new Phar($pharFile);
|
||||
|
||||
// start buffering. Mandatory to modify stub to add shebang
|
||||
$phar->startBuffering();
|
||||
|
||||
// Create the default stub from main.php entrypoint
|
||||
$defaultStub = $phar->createDefaultStub('run');
|
||||
|
||||
// Add the rest of the apps files
|
||||
$phar->addFile(__DIR__ . '/run', 'run');
|
||||
$phar->buildFromDirectory(__DIR__, '/vendor(.*)/');
|
||||
$phar->buildFromDirectory(__DIR__, '/Commands(.*)/');
|
||||
|
||||
// Customize the stub to add the shebang
|
||||
$stub = "#!/usr/bin/env php \n" . $defaultStub;
|
||||
|
||||
// Add the stub
|
||||
$phar->setStub($stub);
|
||||
|
||||
$phar->stopBuffering();
|
||||
|
||||
// plus - compressing it into gzip
|
||||
$phar->compressFiles(Phar::GZ);
|
||||
|
||||
# Make the file executable
|
||||
chmod(__DIR__ . "/{$pharFile}", 0770);
|
||||
|
||||
echo "$pharFile successfully created" . PHP_EOL;
|
||||
} catch (Exception $e) {
|
||||
echo $e->getMessage();
|
||||
}
|
21
scripts/composer.json
Normal file
21
scripts/composer.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"require": {
|
||||
"symfony/console": "^6.0",
|
||||
"symfony/process": "^6.0",
|
||||
"vlucas/phpdotenv": "^5.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cli\\Commands\\": "Commands/",
|
||||
"Cli\\Services\\": "Services/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.0.2"
|
||||
}
|
||||
}
|
||||
}
|
1032
scripts/composer.lock
generated
Normal file
1032
scripts/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
scripts/run
Normal file
35
scripts/run
Normal file
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
exit;
|
||||
}
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Cli\Commands\BackupCommand;
|
||||
use Cli\Commands\InitCommand;
|
||||
use Cli\Commands\RestoreCommand;
|
||||
use Cli\Commands\UpdateCommand;
|
||||
use Symfony\Component\Console\Application;
|
||||
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());
|
||||
$app->add(new UpdateCommand());
|
||||
$app->add(new InitCommand());
|
||||
$app->add(new RestoreCommand());
|
||||
|
||||
try {
|
||||
$app->run();
|
||||
} catch (Exception $error) {
|
||||
$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);
|
||||
}
|
Loading…
Reference in a new issue