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