]> BookStack Code Mirror - system-cli/commitdiff
Added dep check and composer auto-install to init command
authorDan Brown <redacted>
Sat, 4 Mar 2023 19:23:44 +0000 (19:23 +0000)
committerDan Brown <redacted>
Thu, 9 Mar 2023 15:28:13 +0000 (15:28 +0000)
.gitignore
scripts/Commands/InitCommand.php
scripts/Services/ProgramRunner.php

index 90b80e7b85df2cc6ca596a2f5e0c41f928a5bd61..da0d475b6f7ffcf85e5c60a8d3f3af7f3d2bd798 100644 (file)
@@ -25,4 +25,5 @@ nbproject
 webpack-stats.json
 .phpunit.result.cache
 .DS_Store
-phpstan.neon
\ No newline at end of file
+phpstan.neon
+/composer
\ No newline at end of file
index 24a8d8134dc76cc6baf6da9eed5d661a895be9c8..262806569255fb00ae250423827f1cbffa760614 100644 (file)
@@ -13,10 +13,8 @@ class InitCommand
      */
     public function handle(CommandCall $input)
     {
-        $this->ensureRequiredExtensionInstalled(); // TODO - Ensure bookstack install deps are met?
-
-        // TODO - Check composer and git exists before running
-        // TODO - Potentially download composer?
+        echo "Checking system requirements...\n";
+        $this->ensureRequirementsMet();
 
         $suggestedOutPath = $input->subcommand;
         if ($suggestedOutPath === 'default') {
@@ -25,13 +23,22 @@ class InitCommand
 
         echo "Locating and checking install directory...\n";
         $installDir = $this->getInstallDir($suggestedOutPath);
-        $this->ensureInstallDirEmpty($installDir);
+        $this->ensureInstallDirEmptyAndWritable($installDir);
 
         echo "Cloning down BookStack project to install directory...\n";
         $this->cloneBookStackViaGit($installDir);
 
+        echo "Checking composer exists...\n";
+        $composer = $this->getComposerProgram($installDir);
+        try {
+            $composer->ensureFound();
+        } catch (\Exception $exception) {
+            echo "Composer does not exist, downloading a local copy...\n";
+            $this->downloadComposerToInstall($installDir);
+        }
+
         echo "Installing application dependencies using composer...\n";
-        $this->installComposerDependencies($installDir);
+        $this->installComposerDependencies($composer, $installDir);
 
         echo "Creating .env file from .env.example...\n";
         copy($installDir . DIRECTORY_SEPARATOR . '.env.example', $installDir . DIRECTORY_SEPARATOR . '.env');
@@ -53,11 +60,68 @@ class InitCommand
      * Ensure the required PHP extensions are installed for this command.
      * @throws CommandError
      */
-    protected function ensureRequiredExtensionInstalled(): void
+    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
     {
-//        if (!extension_loaded('zip')) {
-//            throw new CommandError('The "zip" PHP extension is required to run this command');
-//        }
+        return (new ProgramRunner('composer', '/usr/local/bin/composer'))
+            ->withTimeout(300)
+            ->withIdleTimeout(15)
+            ->withAdditionalPathLocation($installDir);
     }
 
     protected function generateAppKey(string $installDir): void
@@ -80,12 +144,9 @@ class InitCommand
      * Run composer install to download PHP dependencies.
      * @throws CommandError
      */
-    protected function installComposerDependencies(string $installDir): void
+    protected function installComposerDependencies(ProgramRunner $composer, string $installDir): void
     {
-        $errors = (new ProgramRunner('composer', '/usr/local/bin/composer'))
-            ->withTimeout(300)
-            ->withIdleTimeout(15)
-            ->runCapturingStdErr([
+        $errors = $composer->runCapturingStdErr([
                 'install',
                 '--no-dev', '-n', '-q', '--no-progress',
                 '-d', $installDir
@@ -122,12 +183,16 @@ class InitCommand
      * Ensure that the installation directory is completely empty to avoid potential conflicts or issues.
      * @throws CommandError
      */
-    protected function ensureInstallDirEmpty(string $installDir): void
+    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.");
+        }
     }
 
     /**
@@ -148,7 +213,7 @@ class InitCommand
                 if (!$created) {
                     throw new CommandError("Could not create directory [{$suggestedDir}] for install.");
                 }
-                $dir = $suggestedDir;
+                $dir = realpath($suggestedDir);
             } else {
                 throw new CommandError("Could not resolve provided [{$suggestedDir}] path to an existing folder.");
             }
index 6f94f1e307865917b45f1c41e8c8e97482bf3bbb..acc51b959b66206102d62e0e021a50640dd438af 100644 (file)
@@ -10,6 +10,7 @@ class ProgramRunner
     protected int $timeout = 240;
     protected int $idleTimeout = 15;
     protected array $environment = [];
+    protected array $additionalProgramDirectories = [];
 
     public function __construct(
         protected string $program,
@@ -35,6 +36,12 @@ class ProgramRunner
         return $this;
     }
 
+    public function withAdditionalPathLocation(string $directoryPath): static
+    {
+        $this->additionalProgramDirectories[] = $directoryPath;
+        return $this;
+    }
+
     public function runCapturingAllOutput(array $args): string
     {
         $output = '';
@@ -55,16 +62,30 @@ class ProgramRunner
         return $err;
     }
 
-    public function runWithoutOutputCallbacks(array $args, callable $stdOutCallback, callable $stdErrCallback): void
+    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) {
-                $stdErrCallback($data);
+                if ($stdErrCallback) {
+                    $stdErrCallback($data);
+                }
             } else {
-                $stdOutCallback($data);
+                if ($stdOutCallback) {
+                    $stdOutCallback($data);
+                }
             }
         }
+
+        return $process->getExitCode() ?? 1;
+    }
+
+    /**
+     * @throws \Exception
+     */
+    public function ensureFound(): void
+    {
+        $this->resolveProgramPath();
     }
 
     protected function startProcess(array $args): Process
@@ -80,7 +101,7 @@ class ProgramRunner
     protected function resolveProgramPath(): string
     {
         $executableFinder = new ExecutableFinder();
-        $path = $executableFinder->find($this->program, $this->defaultPath);
+        $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.");