]> BookStack Code Mirror - system-cli/blob - scripts/Commands/InitCommand.php
6021d482d94eccfb726a66b4c58e745f0083128e
[system-cli] / scripts / Commands / InitCommand.php
1 <?php
2
3 namespace Cli\Commands;
4
5 use Cli\Services\EnvironmentLoader;
6 use Cli\Services\ProgramRunner;
7 use Symfony\Component\Console\Command\Command;
8 use Symfony\Component\Console\Input\InputArgument;
9 use Symfony\Component\Console\Input\InputInterface;
10 use Symfony\Component\Console\Output\OutputInterface;
11
12 class InitCommand extends Command
13 {
14     protected function configure(): void
15     {
16         $this->setName('init');
17         $this->setDescription('Initialise a new BookStack install. Does not configure the webserver or database.');
18         $this->addArgument('target-directory', InputArgument::OPTIONAL, 'The directory to create the BookStack install within. Must be empty.', '');
19     }
20
21     /**
22      * @throws CommandError
23      */
24     protected function execute(InputInterface $input, OutputInterface $output): int
25     {
26         $output->writeln("<info>Checking system requirements...</info>");
27         $this->ensureRequirementsMet();
28
29         $suggestedOutPath = $input->getArgument('target-directory');
30
31         $output->writeln("<info>Locating and checking install directory...</info>");
32         $installDir = $this->getInstallDir($suggestedOutPath);
33         $this->ensureInstallDirEmptyAndWritable($installDir);
34
35         $output->writeln("<info>Cloning down BookStack project to install directory...</info>");
36         $this->cloneBookStackViaGit($installDir);
37
38         $output->writeln("<info>Checking composer exists...</info>");
39         $composer = $this->getComposerProgram($installDir);
40         try {
41             $composer->ensureFound();
42         } catch (\Exception $exception) {
43             $output->writeln("<info>Composer does not exist, downloading a local copy...</info>");
44             $this->downloadComposerToInstall($installDir);
45         }
46
47         $output->writeln("<info>Installing application dependencies using composer...</info>");
48         $this->installComposerDependencies($composer, $installDir);
49
50         $output->writeln("<info>Creating .env file from .env.example...</info>");
51         copy($installDir . DIRECTORY_SEPARATOR . '.env.example', $installDir . DIRECTORY_SEPARATOR . '.env');
52         sleep(1);
53
54         $output->writeln("<info>Generating app key...</info>");
55         $this->generateAppKey($installDir);
56
57         // Announce end
58         $output->writeln("<info>A BookStack install has been initialized at: {$installDir}\n</info>");
59         $output->writeln("<info>You will still need to:</info>");
60         $output->writeln("<info>- Update the .env file in the install with correct URL, database and email details.</info>");
61         $output->writeln("<info>- Run 'php artisan migrate' to set-up the database.</info>");
62         $output->writeln("<info>- Configure your webserver for use with BookStack.</info>");
63         $output->writeln("<info>- Ensure the required directories (storage/ bootstrap/cache public/uploads) are web-server writable.</info>");
64
65         return Command::SUCCESS;
66     }
67
68     /**
69      * Ensure the required PHP extensions are installed for this command.
70      * @throws CommandError
71      */
72     protected function ensureRequirementsMet(): void
73     {
74         $errors = [];
75
76         if (version_compare(PHP_VERSION, '8.0.2') < 0) {
77             $errors[] = "PHP >= 8.0.2 is required to install BookStack.";
78         }
79
80         $requiredExtensions = ['bcmath', 'curl', 'gd', 'iconv', 'libxml', 'mbstring', 'mysqlnd', 'xml'];
81         foreach ($requiredExtensions as $extension) {
82             if (!extension_loaded($extension)) {
83                 $errors[] = "The \"{$extension}\" PHP extension is required by not active.";
84             }
85         }
86
87         try {
88             (new ProgramRunner('git', '/usr/bin/git'))->ensureFound();
89             (new ProgramRunner('php', '/usr/bin/php'))->ensureFound();
90         } catch (\Exception $exception) {
91             $errors[] = $exception->getMessage();
92         }
93
94         if (count($errors) > 0) {
95             throw new CommandError("Requirements failed with following errors:\n" . implode("\n", $errors));
96         }
97     }
98
99     protected function downloadComposerToInstall(string $installDir): void
100     {
101         $setupPath = $installDir . DIRECTORY_SEPARATOR . 'composer-setup.php';
102         $signature = file_get_contents('https://composer.github.io/installer.sig');
103         copy('https://getcomposer.org/installer', $setupPath);
104         $checksum = hash_file('sha384', $setupPath);
105
106         if ($signature !== $checksum) {
107             unlink($setupPath);
108             throw new CommandError("Could not install composer, checksum validation failed.");
109         }
110
111         $status = (new ProgramRunner('php', '/usr/bin/php'))
112             ->runWithoutOutputCallbacks([
113                 $setupPath, '--quiet',
114                 "--install-dir={$installDir}",
115                 "--filename=composer",
116             ]);
117
118         unlink($setupPath);
119
120         if ($status !== 0) {
121             throw new CommandError("Could not install composer, composer-setup script run failed.");
122         }
123     }
124
125     /**
126      * Get the composer program.
127      */
128     protected function getComposerProgram(string $installDir): ProgramRunner
129     {
130         return (new ProgramRunner('composer', '/usr/local/bin/composer'))
131             ->withTimeout(300)
132             ->withIdleTimeout(15)
133             ->withAdditionalPathLocation($installDir);
134     }
135
136     protected function generateAppKey(string $installDir): void
137     {
138         $errors = (new ProgramRunner('php', '/usr/bin/php'))
139             ->withTimeout(60)
140             ->withIdleTimeout(5)
141             ->withEnvironment(EnvironmentLoader::load($installDir))
142             ->runCapturingAllOutput([
143                 $installDir . DIRECTORY_SEPARATOR . 'artisan',
144                 'key:generate', '--force', '-n', '-q'
145             ]);
146
147         if ($errors) {
148             throw new CommandError("Failed 'php artisan key:generate' with errors:\n" . $errors);
149         }
150     }
151
152     /**
153      * Run composer install to download PHP dependencies.
154      * @throws CommandError
155      */
156     protected function installComposerDependencies(ProgramRunner $composer, string $installDir): void
157     {
158         $errors = $composer->runCapturingStdErr([
159                 'install',
160                 '--no-dev', '-n', '-q', '--no-progress',
161                 '-d', $installDir
162             ]);
163
164         if ($errors) {
165             throw new CommandError("Failed composer install with errors:\n" . $errors);
166         }
167     }
168
169     /**
170      * Clone a new instance of BookStack to the given install folder.
171      * @throws CommandError
172      */
173     protected function cloneBookStackViaGit(string $installDir): void
174     {
175         $errors = (new ProgramRunner('git', '/usr/bin/git'))
176             ->withTimeout(240)
177             ->withIdleTimeout(15)
178             ->runCapturingStdErr([
179                 'clone', '-q',
180                 '--branch', 'release',
181                 '--single-branch',
182                 'https://github.com/BookStackApp/BookStack.git',
183                 $installDir
184             ]);
185
186         if ($errors) {
187             throw new CommandError("Failed git clone with errors:\n" . $errors);
188         }
189     }
190
191     /**
192      * Ensure that the installation directory is completely empty to avoid potential conflicts or issues.
193      * @throws CommandError
194      */
195     protected function ensureInstallDirEmptyAndWritable(string $installDir): void
196     {
197         $contents = array_diff(scandir($installDir), ['..', '.']);
198         if (count($contents) > 0) {
199             throw new CommandError("Expected install directory to be empty but existing files found in [{$installDir}] target location.");
200         }
201
202         if (!is_writable($installDir)) {
203             throw new CommandError("Target install directory [{$installDir}] is not writable.");
204         }
205     }
206
207     /**
208      * Build a full path to the intended location for the BookStack install.
209      * @throws CommandError
210      */
211     protected function getInstallDir(string $suggestedDir): string
212     {
213         $dir = getcwd();
214
215         if ($suggestedDir) {
216             if (is_file($suggestedDir)) {
217                 throw new CommandError("Was provided [{$suggestedDir}] as an install path but existing file provided.");
218             } else if (is_dir($suggestedDir)) {
219                 $dir = realpath($suggestedDir);
220             } else if (is_dir(dirname($suggestedDir))) {
221                 $created = mkdir($suggestedDir);
222                 if (!$created) {
223                     throw new CommandError("Could not create directory [{$suggestedDir}] for install.");
224                 }
225                 $dir = realpath($suggestedDir);
226             } else {
227                 throw new CommandError("Could not resolve provided [{$suggestedDir}] path to an existing folder.");
228             }
229         }
230
231         return $dir;
232     }
233 }