require __DIR__ . '/vendor/autoload.php';
+use Cli\Commands\CommandError;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\ConsoleOutput;
// Run the command and handle errors
try {
- $output->writeln("<warn>WARNING: This CLI is in early alpha testing.</warn>");
- $output->writeln("<warn>There's a high chance of running into bugs, and the CLI API is subject to change.</warn>");
+ $output->writeln("<warn>WARNING: This CLI is in alpha testing.</warn>");
+ $output->writeln("<warn>There's a high chance of issues, and the CLI API is subject to change.</warn>");
$output->writeln("");
$app->run(null, $output);
+} catch (CommandError $error) {
+ $output = (new ConsoleOutput())->getErrorOutput();
+ $output->getFormatter()->setStyle('error', new OutputFormatterStyle('red'));
+ $output->writeln('<error>' . $error->getMessage() . '</error>');
+ exit(1);
} catch (Exception $error) {
$output = (new ConsoleOutput())->getErrorOutput();
$output->getFormatter()->setStyle('error', new OutputFormatterStyle('red'));
--- /dev/null
+<?php
+
+namespace Cli\Commands;
+
+use Cli\Services\AppLocator;
+use Exception;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use ZipArchive;
+
+class DownloadVendorCommand extends Command
+{
+ protected function configure(): void
+ {
+ $this->setName('download-vendor');
+ $this->setDescription('Download and extract PHP vendor files in a BookStack instance.');
+ $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to download into', '');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $appDir = AppLocator::require($input->getOption('app-directory'));
+
+ $output->writeln("<info>Checking app version...</info>");
+ $version = AppLocator::getVersion($appDir);
+ if (empty($version)) {
+ throw new CommandError("Could not determine instance BookStack version.");
+ }
+ $targetChecksum = $this->getTargetChecksum($appDir);
+
+ $output->writeln("<info>Downloading ZIP from files.bookstackapp.com...</info>");
+ $zip = $this->downloadVendorZip($version);
+
+ $output->writeln("<info>Validating downloaded ZIP...</info>");
+ $this->verifyZipChecksum($zip, $targetChecksum);
+
+ $output->writeln("<info>Deleting existing vendor/ directory...</info>");
+ try {
+ $this->deleteAppVendorFiles($appDir);
+ } catch (Exception $exception) {
+ unlink($zip);
+ throw $exception;
+ }
+
+ $output->writeln("<info>Extracting ZIP into BookStack instance...</info>");
+ $this->extractZip($zip, $appDir);
+
+ $output->writeln("<info>Cleaning up old app services...</info>");
+ $cleaned = $this->cleanupAppServices($appDir);
+ if (!$cleaned) {
+ $output->writeln("<warning>Failed to remove exising app services file</warning>");
+ }
+
+ $output->writeln("<success>Successfully downloaded & extracted vendor files into BookStack instance!</success>");
+
+ return Command::SUCCESS;
+ }
+
+ protected function cleanupAppServices(string $appDir): bool
+ {
+ $servicesFile = implode(DIRECTORY_SEPARATOR, [$appDir, 'bootstrap', 'cache', 'services.php']);
+ if (file_exists($servicesFile)) {
+ return @unlink($servicesFile);
+ }
+
+ return true;
+ }
+
+ protected function extractZip(string $zipPath, string $appDir): void
+ {
+ $zip = new ZipArchive();
+ $opened = $zip->open($zipPath, ZipArchive::RDONLY);
+ $extracted = $zip->extractTo($appDir);
+ $closed = $zip->close();
+
+ unlink($zipPath);
+ if (!$opened || !$extracted || !$closed) {
+ throw new CommandError("Failed to extract ZIP files into {$appDir}");
+ }
+ }
+
+ protected function deleteAppVendorFiles(string $appDir): void
+ {
+ $targetDir = $appDir . DIRECTORY_SEPARATOR . 'vendor';
+ if (!is_dir($targetDir)) {
+ return;
+ }
+
+ $it = new RecursiveDirectoryIterator($targetDir, RecursiveDirectoryIterator::SKIP_DOTS);
+ $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
+ foreach($files as $file) {
+ if ($file->isDir()){
+ rmdir($file->getPathname());
+ } else {
+ unlink($file->getPathname());
+ }
+ }
+
+ $deleted = rmdir($targetDir);
+ if (!$deleted) {
+ throw new CommandError("Could not delete existing app vendor directory.");
+ }
+ }
+
+ protected function verifyZipChecksum(string $zipPath, string $targetChecksum): void
+ {
+ $zipChecksum = hash_file('sha256', $zipPath);
+ if ($zipChecksum !== $targetChecksum) {
+ unlink($zipPath);
+ throw new CommandError("Checksum of downloaded ZIP does not match the expected checksum.");
+ }
+ }
+
+ protected function downloadVendorZip(string $version): string
+ {
+ $tempFile = tempnam(sys_get_temp_dir(), 'bs-cli-vendor-zip');
+ $targetUrl = "https://files.bookstackapp.com/vendor/{$version}.zip";
+
+ file_put_contents($tempFile, fopen($targetUrl, 'rb'));
+
+ return $tempFile;
+ }
+
+ /**
+ * @throws CommandError
+ */
+ protected function getTargetChecksum(string $appDir): string
+ {
+ $checksumFile = implode(DIRECTORY_SEPARATOR, [$appDir, 'dev', 'checksums', 'vendor']);
+ $checksum = '';
+ if (file_exists($checksumFile)) {
+ $checksum = trim(file_get_contents($checksumFile));
+ }
+
+ if (empty($checksum)) {
+ throw new CommandError("Could not find a vendor checksum for validation.");
+ }
+
+ return $checksum;
+ }
+}