├── .github └── workflows │ ├── pint-fix.yml │ └── pint-link.yml ├── .gitignore ├── README.md ├── bin └── statamic ├── composer.json ├── pint.json ├── src ├── Concerns │ ├── ConfiguresDatabase.php │ ├── ConfiguresPrompts.php │ └── RunsCommands.php ├── NewCommand.php ├── Please.php ├── Theme │ ├── ConfirmPromptRenderer.php │ ├── SelectPromptRenderer.php │ ├── SuggestPromptRenderer.php │ ├── Teal.php │ └── TextPromptRenderer.php ├── UpdateCommand.php ├── Version.php └── VersionCommand.php ├── tests-output └── .gitignore └── tests └── NewCommandIntegrationTest.php /.github/workflows/pint-fix.yml: -------------------------------------------------------------------------------- 1 | name: Fix PHP code style issues 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | fix-php-code-styling: 13 | runs-on: ubuntu-latest 14 | if: github.repository_owner == 'statamic' 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.head_ref }} 21 | token: ${{ secrets.PINT }} 22 | 23 | - name: Fix PHP code style issues 24 | uses: aglipanci/laravel-pint-action@v2 25 | with: 26 | pintVersion: 1.16.0 27 | 28 | - name: Commit changes 29 | uses: stefanzweifel/git-auto-commit-action@v5 30 | with: 31 | commit_message: Fix styling -------------------------------------------------------------------------------- /.github/workflows/pint-link.yml: -------------------------------------------------------------------------------- 1 | name: Lint PHP code style issues 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.php' 7 | 8 | jobs: 9 | lint-php-code-styling: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Check PHP code style issues 17 | uses: aglipanci/laravel-pint-action@v2 18 | with: 19 | testMode: true 20 | verboseMode: true 21 | pintVersion: 1.16.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | .DS_Store 3 | composer.lock 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Statamic CLI Tool 2 | 3 | 🌴 Install and manage your **Statamic** projects from the command line. 4 | 5 | - [Installing the CLI tool](#installing-the-cli-tool) 6 | - [Using the CLI tool](#using-the-cli-tool) 7 | - [Installing Statamic](#installing-statamic) 8 | - [Checking Statamic versions](#checking-statamic-versions) 9 | - [Updating Statamic](#updating-statamic) 10 | 11 | ## Installing the CLI tool 12 | 13 | ``` 14 | composer global require statamic/cli 15 | ``` 16 | 17 | Make sure to place Composer's system-wide vendor bin directory in your `$PATH` so the `statamic` executable can be located by your system. [Here's how](https://statamic.dev/troubleshooting/command-not-found-statamic). 18 | 19 | Once installed, you should be able to run `statamic {command name}` from within any directory. 20 | 21 | ### GitHub authentication 22 | 23 | When you install starter kits, the CLI might present you with a warning that the GitHub API limit is reached. [Generate a Personal access token](https://github.com/settings/tokens/new) and paste it in your terminal with this command so Composer will save it for future use: 24 | 25 | ```bash 26 | composer config --global --auth github-oauth.github.com [your_token_here] 27 | ``` 28 | 29 | Read more on this in the [Composer Docs](https://getcomposer.org/doc/articles/authentication-for-private-packages.md). 30 | 31 | ## Updating the CLI tool 32 | 33 | ``` 34 | composer global update statamic/cli 35 | ``` 36 | 37 | Run this command to update the CLI tool to the most recent published version. If there's been a major version release, you may need to run `require` instead of update. 38 | 39 | ## Using the CLI tool 40 | 41 | ### Installing Statamic 42 | 43 | You may create a new Statamic site with the `new` command: 44 | 45 | ``` 46 | statamic new my-site 47 | ``` 48 | 49 | This will present you with a list of supported starter kits to select from. Upon selection, the latest version will be downloaded and installed into the `my-site` directory. 50 | 51 | You may also pass an explicit starter kit repo if you wish to skip the selection prompt: 52 | 53 | ``` 54 | statamic new my-site statamic/starter-kit-cool-writings 55 | ``` 56 | 57 | ### Checking Statamic versions 58 | 59 | From within an existing Statamic project root directory, you may run the following command to quickly find out which version is being used. 60 | 61 | ``` 62 | statamic version 63 | ``` 64 | 65 | ### Updating Statamic 66 | 67 | From within an existing Statamic project root directory, you may use the following command to update to the latest version. 68 | 69 | ``` 70 | statamic update 71 | ``` 72 | 73 | This is just syntactic sugar for the `composer update statamic/cms --with-dependencies` command. 74 | -------------------------------------------------------------------------------- /bin/statamic: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new Statamic\Cli\NewCommand); 26 | $app->add(new Statamic\Cli\UpdateCommand); 27 | $app->add(new Statamic\Cli\VersionCommand); 28 | 29 | $app->run(); 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statamic/cli", 3 | "description": "Statamic CLI Tool", 4 | "keywords": ["statamic"], 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.1", 8 | "guzzlehttp/guzzle": "^6.5.5|^7.0.1", 9 | "laravel/prompts": "^0.1.3|^0.2.0|^0.3.0", 10 | "illuminate/support": "^10.0|^11.0|^12.0", 11 | "symfony/console": "^4.0|^5.0|^6.0|^7.0", 12 | "symfony/process": "^4.2|^5.0|^6.0|^7.0" 13 | }, 14 | "require-dev": { 15 | "phpunit/phpunit": "^8.0" 16 | }, 17 | "bin": [ 18 | "bin/statamic" 19 | ], 20 | "autoload": { 21 | "psr-4": { 22 | "Statamic\\Cli\\": "src/" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Statamic\\Cli\\Tests\\": "tests/" 28 | } 29 | }, 30 | "config": { 31 | "sort-packages": true 32 | }, 33 | "minimum-stability": "dev", 34 | "prefer-stable": true 35 | } 36 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "binary_operator_spaces": { 5 | "default": "single_space", 6 | "operators": { 7 | "=>": null 8 | } 9 | }, 10 | "class_attributes_separation": { 11 | "elements": { 12 | "method": "one" 13 | } 14 | }, 15 | "class_definition": { 16 | "multi_line_extends_each_single_line": true, 17 | "single_item_single_line": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Concerns/ConfiguresDatabase.php: -------------------------------------------------------------------------------- 1 | databaseOptions() 20 | )->keys()->first(); 21 | 22 | if ($this->input->isInteractive()) { 23 | $database = select( 24 | label: 'Which database will your application use?', 25 | options: $databaseOptions, 26 | default: $defaultDatabase, 27 | ); 28 | } 29 | 30 | return $database ?? $defaultDatabase; 31 | } 32 | 33 | /** 34 | * Get the available database options. 35 | */ 36 | protected function databaseOptions(): array 37 | { 38 | return collect([ 39 | 'sqlite' => ['SQLite', extension_loaded('pdo_sqlite')], 40 | 'mysql' => ['MySQL', extension_loaded('pdo_mysql')], 41 | 'mariadb' => ['MariaDB', extension_loaded('pdo_mysql')], 42 | 'pgsql' => ['PostgreSQL', extension_loaded('pdo_pgsql')], 43 | 'sqlsrv' => ['SQL Server', extension_loaded('pdo_sqlsrv')], 44 | ]) 45 | ->sortBy(fn ($database) => $database[1] ? 0 : 1) 46 | ->map(fn ($database) => $database[0].($database[1] ? '' : ' (Missing PDO extension)')) 47 | ->all(); 48 | } 49 | 50 | /** 51 | * Configure the default database connection. 52 | */ 53 | protected function configureDefaultDatabaseConnection(string $database, string $name): void 54 | { 55 | $this->pregReplaceInFile( 56 | '/DB_CONNECTION=.*/', 57 | 'DB_CONNECTION='.$database, 58 | $this->absolutePath.'/.env' 59 | ); 60 | 61 | $this->pregReplaceInFile( 62 | '/DB_CONNECTION=.*/', 63 | 'DB_CONNECTION='.$database, 64 | $this->absolutePath.'/.env.example' 65 | ); 66 | 67 | if ($database === 'sqlite') { 68 | $environment = file_get_contents($this->absolutePath.'/.env'); 69 | 70 | // If database options aren't commented, comment them for SQLite... 71 | if (! str_contains($environment, '# DB_HOST=127.0.0.1')) { 72 | $this->commentDatabaseConfigurationForSqlite($this->absolutePath); 73 | 74 | return; 75 | } 76 | 77 | return; 78 | } 79 | 80 | // Any commented database configuration options should be uncommented when not on SQLite... 81 | $this->uncommentDatabaseConfiguration($this->absolutePath); 82 | 83 | $defaultPorts = [ 84 | 'pgsql' => '5432', 85 | 'sqlsrv' => '1433', 86 | ]; 87 | 88 | if (isset($defaultPorts[$database])) { 89 | $this->replaceInFile( 90 | 'DB_PORT=3306', 91 | 'DB_PORT='.$defaultPorts[$database], 92 | $this->absolutePath.'/.env' 93 | ); 94 | 95 | $this->replaceInFile( 96 | 'DB_PORT=3306', 97 | 'DB_PORT='.$defaultPorts[$database], 98 | $this->absolutePath.'/.env.example' 99 | ); 100 | } 101 | 102 | $this->replaceInFile( 103 | 'DB_DATABASE=laravel', 104 | 'DB_DATABASE='.str_replace('-', '_', strtolower($name)), 105 | $this->absolutePath.'/.env' 106 | ); 107 | 108 | $this->replaceInFile( 109 | 'DB_DATABASE=laravel', 110 | 'DB_DATABASE='.str_replace('-', '_', strtolower($name)), 111 | $this->absolutePath.'/.env.example' 112 | ); 113 | } 114 | 115 | /** 116 | * Comment the irrelevant database configuration entries for SQLite applications. 117 | */ 118 | protected function commentDatabaseConfigurationForSqlite(string $directory): void 119 | { 120 | $defaults = [ 121 | 'DB_HOST=127.0.0.1', 122 | 'DB_PORT=3306', 123 | 'DB_DATABASE=laravel', 124 | 'DB_USERNAME=root', 125 | 'DB_PASSWORD=', 126 | ]; 127 | 128 | $this->replaceInFile( 129 | $defaults, 130 | collect($defaults)->map(fn ($default) => "# {$default}")->all(), 131 | $directory.'/.env' 132 | ); 133 | 134 | $this->replaceInFile( 135 | $defaults, 136 | collect($defaults)->map(fn ($default) => "# {$default}")->all(), 137 | $directory.'/.env.example' 138 | ); 139 | } 140 | 141 | /** 142 | * Uncomment the relevant database configuration entries for non SQLite applications. 143 | */ 144 | protected function uncommentDatabaseConfiguration(string $directory): void 145 | { 146 | $defaults = [ 147 | '# DB_HOST=127.0.0.1', 148 | '# DB_PORT=3306', 149 | '# DB_DATABASE=laravel', 150 | '# DB_USERNAME=root', 151 | '# DB_PASSWORD=', 152 | ]; 153 | 154 | $this->replaceInFile( 155 | $defaults, 156 | collect($defaults)->map(fn ($default) => substr($default, 2))->all(), 157 | $directory.'/.env' 158 | ); 159 | 160 | $this->replaceInFile( 161 | $defaults, 162 | collect($defaults)->map(fn ($default) => substr($default, 2))->all(), 163 | $directory.'/.env.example' 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Concerns/ConfiguresPrompts.php: -------------------------------------------------------------------------------- 1 | isInteractive() || PHP_OS_FAMILY === 'Windows'); 25 | 26 | TextPrompt::fallbackUsing(fn (TextPrompt $prompt) => $this->promptUntilValid( 27 | fn () => (new SymfonyStyle($input, $output))->ask($prompt->label, $prompt->default ?: null) ?? '', 28 | $prompt->required, 29 | $prompt->validate, 30 | $output 31 | )); 32 | 33 | ConfirmPrompt::fallbackUsing(fn (ConfirmPrompt $prompt) => $this->promptUntilValid( 34 | fn () => (new SymfonyStyle($input, $output))->confirm($prompt->label, $prompt->default), 35 | $prompt->required, 36 | $prompt->validate, 37 | $output 38 | )); 39 | 40 | SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid( 41 | fn () => (new SymfonyStyle($input, $output))->choice($prompt->label, $prompt->options, $prompt->default), 42 | false, 43 | $prompt->validate, 44 | $output 45 | )); 46 | 47 | SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid( 48 | function () use ($prompt, $input, $output) { 49 | $question = new Question($prompt->label, $prompt->default); 50 | 51 | is_callable($prompt->options) 52 | ? $question->setAutocompleterCallback($prompt->options) 53 | : $question->setAutocompleterValues($prompt->options); 54 | 55 | return (new SymfonyStyle($input, $output))->askQuestion($question); 56 | }, 57 | $prompt->required, 58 | $prompt->validate, 59 | $output 60 | )); 61 | } 62 | 63 | /** 64 | * Prompt the user until the given validation callback passes. 65 | * 66 | * @param \Closure $prompt 67 | * @param bool|string $required 68 | * @param \Closure|null $validate 69 | * @param \Symfony\Component\Console\Output\OutputInterface $output 70 | * @return mixed 71 | */ 72 | protected function promptUntilValid($prompt, $required, $validate, $output) 73 | { 74 | while (true) { 75 | $result = $prompt(); 76 | 77 | if ($required && ($result === '' || $result === [] || $result === false)) { 78 | $output->writeln(''.(is_string($required) ? $required : 'Required.').''); 79 | 80 | continue; 81 | } 82 | 83 | if ($validate) { 84 | $error = $validate($result); 85 | 86 | if (is_string($error) && strlen($error) > 0) { 87 | $output->writeln("{$error}"); 88 | 89 | continue; 90 | } 91 | } 92 | 93 | return $result; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Concerns/RunsCommands.php: -------------------------------------------------------------------------------- 1 | runCommands([$command], $workingPath, $disableOutput); 17 | } 18 | 19 | /** 20 | * Run the given commands. 21 | * 22 | * @return Process 23 | */ 24 | protected function runCommands(array $commands, ?string $workingPath = null, bool $disableOutput = false) 25 | { 26 | if (! $this->output->isDecorated()) { 27 | $commands = array_map(function ($value) { 28 | if (str_starts_with($value, 'chmod')) { 29 | return $value; 30 | } 31 | 32 | if (str_starts_with($value, 'git')) { 33 | return $value; 34 | } 35 | 36 | return $value.' --no-ansi'; 37 | }, $commands); 38 | } 39 | 40 | if ($this->input->getOption('quiet')) { 41 | $commands = array_map(function ($value) { 42 | if (str_starts_with($value, 'chmod')) { 43 | return $value; 44 | } 45 | 46 | if (str_starts_with($value, 'git')) { 47 | return $value; 48 | } 49 | 50 | return $value.' --quiet'; 51 | }, $commands); 52 | } 53 | 54 | $process = Process::fromShellCommandline(implode(' && ', $commands), $workingPath, timeout: null); 55 | 56 | if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { 57 | try { 58 | if ($this->input->hasOption('no-interaction') && $this->input->getOption('no-interaction')) { 59 | $process->setTty(false); 60 | } else { 61 | $process->setTty(true); 62 | } 63 | } catch (RuntimeException $e) { 64 | $this->output->writeln(' WARN '.$e->getMessage().PHP_EOL); 65 | } 66 | } 67 | 68 | if ($disableOutput) { 69 | $process->disableOutput()->run(); 70 | } else { 71 | $process->run(function ($type, $line) { 72 | $this->output->write(' '.$line); 73 | }); 74 | } 75 | 76 | return $process; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/NewCommand.php: -------------------------------------------------------------------------------- 1 | setName('new') 77 | ->setDescription('Create a new Statamic application') 78 | ->addArgument('name', InputArgument::REQUIRED, 'Statamic application directory name') 79 | ->addOption('dev', null, InputOption::VALUE_NONE, 'Installs the latest "development" release') 80 | ->addArgument('starter-kit', InputArgument::OPTIONAL, 'Optionally install specific starter kit') 81 | ->addOption('license', null, InputOption::VALUE_OPTIONAL, 'Optionally provide explicit starter kit license') 82 | ->addOption('local', null, InputOption::VALUE_NONE, 'Optionally install from local repo configured in composer config.json') 83 | ->addOption('with-config', null, InputOption::VALUE_NONE, 'Optionally copy starter-kit.yaml config for local development') 84 | ->addOption('without-dependencies', null, InputOption::VALUE_NONE, 'Optionally install starter kit without dependencies') 85 | ->addOption('pro', null, InputOption::VALUE_NONE, 'Enable Statamic Pro for additional features') 86 | ->addOption('ssg', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Optionally install the Static Site Generator addon', []) 87 | ->addOption('git', null, InputOption::VALUE_NONE, 'Initialize a Git repository') 88 | ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'The branch that should be created for a new repository') 89 | ->addOption('github', null, InputOption::VALUE_OPTIONAL, 'Create a new repository on GitHub', false) 90 | ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'Optionally specify the name of the GitHub repository') 91 | ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force install even if the directory already exists') 92 | ->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Creates a super user with this email address') 93 | ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password for the super user'); 94 | } 95 | 96 | protected function initialize(InputInterface $input, OutputInterface $output) 97 | { 98 | $this->input = $input; 99 | $this->output = $output; 100 | 101 | $this->configurePrompts($input, $output); 102 | 103 | $this 104 | ->setupTheme() 105 | ->checkCliVersion() 106 | ->notifyIfOldCliVersion() 107 | ->showStatamicTitleArt(); 108 | } 109 | 110 | protected function interact(InputInterface $input, OutputInterface $output) 111 | { 112 | if (! $this->input->getArgument('name')) { 113 | $this->input->setArgument('name', text( 114 | label: 'What is the name of your project?', 115 | placeholder: 'E.g. example-app', 116 | required: 'The project name is required.', 117 | validate: fn ($value) => preg_match('/[^\pL\pN\-_.]/', $value) !== 0 118 | ? 'The name may only contain letters, numbers, dashes, underscores, and periods.' 119 | : null, 120 | )); 121 | } 122 | } 123 | 124 | /** 125 | * Execute the command. 126 | */ 127 | protected function execute(InputInterface $input, OutputInterface $output): int 128 | { 129 | try { 130 | $this 131 | ->processArguments() 132 | ->validateArguments() 133 | ->askForRepo() 134 | ->validateStarterKitLicense() 135 | ->askToInstallEloquentDriver() 136 | ->askToEnableStatamicPro() 137 | ->askToInstallSsg() 138 | ->askToMakeSuperUser() 139 | ->askToInitializeGitRepository() 140 | ->askToPushToGithub() 141 | ->askToSpreadJoy() 142 | ->installBaseProject() 143 | ->installStarterKit() 144 | ->enableStatamicPro() 145 | ->makeSuperUser() 146 | ->configureDatabaseConnection() 147 | ->installEloquentDriver() 148 | ->installSsg() 149 | ->initializeGitRepository() 150 | ->pushToGithub() 151 | ->notifyIfOldCliVersion() 152 | ->showSuccessMessage() 153 | ->showPostInstallInstructions(); 154 | } catch (RuntimeException $e) { 155 | $this->showError($e->getMessage()); 156 | 157 | return 1; 158 | } 159 | 160 | return 0; 161 | } 162 | 163 | protected function promptUntilValid($prompt, $required, $validate, $output) 164 | { 165 | while (true) { 166 | $result = $prompt(); 167 | 168 | if ($required && ($result === '' || $result === [] || $result === false)) { 169 | $output->writeln(''.(is_string($required) ? $required : 'Required.').''); 170 | 171 | continue; 172 | } 173 | 174 | if ($validate) { 175 | $error = $validate($result); 176 | 177 | if (is_string($error) && strlen($error) > 0) { 178 | $output->writeln("{$error}"); 179 | 180 | continue; 181 | } 182 | } 183 | 184 | return $result; 185 | } 186 | } 187 | 188 | protected function setupTheme() 189 | { 190 | Prompt::addTheme('statamic', [ 191 | SelectPrompt::class => SelectPromptRenderer::class, 192 | SuggestPrompt::class => SuggestPromptRenderer::class, 193 | ConfirmPrompt::class => ConfirmPromptRenderer::class, 194 | TextPrompt::class => TextPromptRenderer::class, 195 | ]); 196 | 197 | Prompt::theme('statamic'); 198 | 199 | return $this; 200 | } 201 | 202 | /** 203 | * Check cli version. 204 | * 205 | * @return $this 206 | */ 207 | protected function checkCliVersion() 208 | { 209 | $request = new Client; 210 | 211 | if (! $currentVersion = Version::get()) { 212 | return $this; 213 | } 214 | 215 | try { 216 | $response = $request->get(self::GITHUB_LATEST_RELEASE_ENDPOINT); 217 | $latestVersion = json_decode($response->getBody(), true)['tag_name']; 218 | } catch (\Throwable $exception) { 219 | return $this; 220 | } 221 | 222 | if (version_compare($currentVersion, $latestVersion, '<')) { 223 | $this->shouldUpdateCliToVersion = $latestVersion; 224 | } 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * Notify user if a statamic/cli upgrade exists. 231 | * 232 | * @return $this 233 | */ 234 | protected function notifyIfOldCliVersion() 235 | { 236 | if (! $this->shouldUpdateCliToVersion) { 237 | return $this; 238 | } 239 | 240 | $this->output->write(PHP_EOL); 241 | $this->output->write(" This is an old version of the Statamic CLI Tool, please upgrade to {$this->shouldUpdateCliToVersion}!".PHP_EOL); 242 | $this->output->write(' If you have a global composer installation, you may upgrade by running the following command:'.PHP_EOL); 243 | $this->output->write(' composer global update statamic/cli'.PHP_EOL); 244 | 245 | return $this; 246 | } 247 | 248 | /** 249 | * Process arguments and options. 250 | * 251 | * @return $this 252 | */ 253 | protected function processArguments() 254 | { 255 | $this->relativePath = $this->input->getArgument('name'); 256 | 257 | $this->absolutePath = $this->relativePath && $this->relativePath !== '.' 258 | ? getcwd().'/'.$this->relativePath 259 | : getcwd(); 260 | 261 | $this->name = pathinfo($this->absolutePath)['basename']; 262 | 263 | $this->version = $this->input->getOption('dev') 264 | ? 'dev-master' 265 | : ''; 266 | 267 | $this->starterKit = $this->input->getArgument('starter-kit'); 268 | $this->starterKitLicense = $this->input->getOption('license'); 269 | $this->local = $this->input->getOption('local'); 270 | $this->withConfig = $this->input->getOption('with-config'); 271 | $this->withoutDependencies = $this->input->getOption('without-dependencies'); 272 | $this->pro = $this->input->getOption('pro') ?? true; 273 | $this->ssg = $this->input->getOption('ssg'); 274 | $this->force = $this->input->getOption('force'); 275 | $this->initializeGitRepository = $this->input->getOption('git') !== false || $this->input->getOption('github') !== false; 276 | $this->shouldPushToGithub = $this->input->getOption('github') !== false; 277 | $this->githubRepository = $this->input->getOption('repo'); 278 | $this->repositoryVisibility = $this->input->getOption('github'); 279 | 280 | return $this; 281 | } 282 | 283 | /** 284 | * Validate arguments and options. 285 | * 286 | * @return $this 287 | * 288 | * @throws RuntimeException 289 | */ 290 | protected function validateArguments() 291 | { 292 | if (! $this->force && $this->applicationExists()) { 293 | throw new RuntimeException('Application already exists!'); 294 | } 295 | 296 | if ($this->force && $this->pathIsCwd()) { 297 | throw new RuntimeException('Cannot use --force option when using current directory for installation!'); 298 | } 299 | 300 | if ($this->starterKit && $this->isInvalidStarterKit()) { 301 | throw new RuntimeException('Please enter a valid composer package name (eg. hasselhoff/kung-fury)!'); 302 | } 303 | 304 | if (! $this->starterKit && $this->starterKitLicense) { 305 | throw new RuntimeException('Starter kit is required when using `--license` option!'); 306 | } 307 | 308 | if (! $this->starterKit && $this->local) { 309 | throw new RuntimeException('Starter kit is required when using `--local` option!'); 310 | } 311 | 312 | if (! $this->starterKit && $this->withConfig) { 313 | throw new RuntimeException('Starter kit is required when using `--with-config` option!'); 314 | } 315 | 316 | if (! $this->starterKit && $this->withoutDependencies) { 317 | throw new RuntimeException('Starter kit is required when using `--without-dependencies` option!'); 318 | } 319 | 320 | return $this; 321 | } 322 | 323 | /** 324 | * Show Statamic title art. 325 | * 326 | * @return $this 327 | */ 328 | protected function showStatamicTitleArt() 329 | { 330 | $this->output->write(PHP_EOL.' 331 | █▀ ▀█▀ ▄▀█ ▀█▀ ▄▀█ █▀▄▀█ █ █▀▀ 332 | ▄█ ░█░ █▀█ ░█░ █▀█ █░▀░█ █ █▄▄'.PHP_EOL.PHP_EOL); 333 | 334 | return $this; 335 | } 336 | 337 | /** 338 | * Ask which starter kit repo to install. 339 | * 340 | * @return $this 341 | */ 342 | protected function askForRepo() 343 | { 344 | if ($this->starterKit || ! $this->input->isInteractive()) { 345 | return $this; 346 | } 347 | 348 | $choice = select( 349 | 'Would you like to install a starter kit?', 350 | options: [ 351 | $blankSiteOption = 'No, start with a blank site.', 352 | 'Yes, let me pick a Starter Kit.', 353 | ], 354 | default: $blankSiteOption 355 | ); 356 | 357 | if ($choice === $blankSiteOption) { 358 | return $this; 359 | } 360 | 361 | $this->output->write(' You can find starter kits at https://statamic.com/starter-kits 🏄'.PHP_EOL.PHP_EOL); 362 | 363 | $this->starterKit = $this->normalizeStarterKitSelection(suggest( 364 | 'Which starter kit would you like to install?', 365 | fn ($value) => $this->searchStarterKits($value) 366 | )); 367 | 368 | if ($this->isInvalidStarterKit()) { 369 | throw new RuntimeException('Please enter a valid composer package name (eg. hasselhoff/kung-fury)!'); 370 | } 371 | 372 | return $this; 373 | } 374 | 375 | /** 376 | * Validate starter kit license. 377 | * 378 | * @return $this 379 | */ 380 | protected function validateStarterKitLicense() 381 | { 382 | if (! $this->starterKit) { 383 | return $this; 384 | } 385 | 386 | $request = new Client; 387 | 388 | try { 389 | $response = $request->get(self::OUTPOST_ENDPOINT."{$this->starterKit}"); 390 | } catch (\Exception $exception) { 391 | $this->throwConnectionException(); 392 | } 393 | 394 | $details = json_decode($response->getBody(), true); 395 | 396 | // If $details === `false`, then no product was returned and we'll consider it a free starter kit. 397 | if ($details['data'] === false) { 398 | return $this->confirmUnlistedKit(); 399 | } 400 | 401 | // If the returned product doesn't have a price, then we'll consider it a free starter kit. 402 | if (! $details['data']['price']) { 403 | return $this; 404 | } 405 | 406 | $sellerSlug = $details['data']['seller']['slug']; 407 | $kitSlug = $details['data']['slug']; 408 | $marketplaceUrl = "https://statamic.com/starter-kits/{$sellerSlug}/{$kitSlug}"; 409 | 410 | if ($this->input->isInteractive()) { 411 | $this->output->write(' This is a paid starter kit. If you haven\'t already, you may purchase a license at:'.PHP_EOL); 412 | $this->output->write(" {$marketplaceUrl}".PHP_EOL); 413 | $this->output->write(PHP_EOL); 414 | } 415 | 416 | $license = $this->getStarterKitLicense(); 417 | 418 | try { 419 | $response = $request->post(self::OUTPOST_ENDPOINT.'validate', ['json' => [ 420 | 'license' => $license, 421 | 'package' => $this->starterKit, 422 | ]]); 423 | } catch (\Exception $exception) { 424 | $this->throwConnectionException(); 425 | } 426 | 427 | $validation = json_decode($response->getBody(), true); 428 | 429 | if (! $validation['data']['valid']) { 430 | throw new RuntimeException("Invalid license for [{$this->starterKit}]!"); 431 | } 432 | 433 | $this->output->write('Starter kit license valid!'.PHP_EOL); 434 | 435 | $this->starterKitLicense = $license; 436 | 437 | return $this->confirmSingleSiteLicense(); 438 | } 439 | 440 | /** 441 | * Confirm unlisted kit. 442 | * 443 | * @return $this 444 | */ 445 | protected function confirmUnlistedKit() 446 | { 447 | if (! confirm('Starter kit not found on Statamic Marketplace. Install unlisted starter kit?')) { 448 | return $this->exitInstallation(); 449 | } 450 | 451 | return $this; 452 | } 453 | 454 | /** 455 | * Confirm single-site license. 456 | * 457 | * @return $this 458 | */ 459 | protected function confirmSingleSiteLicense() 460 | { 461 | 462 | $this->output->write(PHP_EOL); 463 | $this->output->write('Once successfully installed, this Starter Kit license will be marked as used'.PHP_EOL); 464 | $this->output->write('and cannot be applied to future installations!'); 465 | 466 | if (! $this->input->isInteractive()) { 467 | return $this; 468 | } 469 | 470 | $this->output->write(PHP_EOL.PHP_EOL); 471 | 472 | if (! confirm('Would you like to continue the installation?', false, 'I understand. Install now and mark used.', "No, I'll install it later.")) { 473 | return $this->exitInstallation(); 474 | } 475 | 476 | return $this; 477 | } 478 | 479 | /** 480 | * Install base project. 481 | * 482 | * @return $this 483 | * 484 | * @throws RuntimeException 485 | */ 486 | protected function installBaseProject() 487 | { 488 | $commands = []; 489 | 490 | if ($this->force && ! $this->pathIsCwd()) { 491 | if (PHP_OS_FAMILY == 'Windows') { 492 | $commands[] = "rd /s /q \"$this->absolutePath\""; 493 | } else { 494 | $commands[] = "rm -rf \"$this->absolutePath\""; 495 | } 496 | } 497 | 498 | $commands[] = $this->createProjectCommand(); 499 | 500 | if (PHP_OS_FAMILY != 'Windows') { 501 | $commands[] = "chmod 755 \"$this->absolutePath/artisan\""; 502 | $commands[] = "chmod 755 \"$this->absolutePath/please\""; 503 | } 504 | 505 | $this->runCommands($commands); 506 | 507 | if (! $this->wasBaseInstallSuccessful()) { 508 | throw new RuntimeException('There was a problem installing Statamic!'); 509 | } 510 | 511 | $this->replaceInFile( 512 | 'APP_URL=http://localhost', 513 | 'APP_URL=http://'.$this->name.'.test', 514 | $this->absolutePath.'/.env' 515 | ); 516 | 517 | $this->baseInstallSuccessful = true; 518 | 519 | return $this; 520 | } 521 | 522 | /** 523 | * Install starter kit. 524 | * 525 | * @return $this 526 | * 527 | * @throws RuntimeException 528 | */ 529 | protected function installStarterKit() 530 | { 531 | if (! $this->baseInstallSuccessful || ! $this->starterKit) { 532 | return $this; 533 | } 534 | 535 | $options = [ 536 | '--cli-install', 537 | '--clear-site', 538 | ]; 539 | 540 | if (! $this->input->isInteractive()) { 541 | $options[] = '--no-interaction'; 542 | } 543 | 544 | if ($this->local) { 545 | $options[] = '--local'; 546 | } 547 | 548 | if ($this->withConfig) { 549 | $options[] = '--with-config'; 550 | } 551 | 552 | if ($this->starterKitLicense) { 553 | $options[] = '--license'; 554 | $options[] = $this->starterKitLicense; 555 | } 556 | 557 | if ($this->withoutDependencies) { 558 | $options[] = '--without-dependencies'; 559 | } 560 | 561 | $statusCode = (new Please($this->output)) 562 | ->cwd($this->absolutePath) 563 | ->run('starter-kit:install', $this->starterKit, ...$options); 564 | 565 | if ($statusCode !== 0) { 566 | throw new RuntimeException('There was a problem installing Statamic with the chosen starter kit!'); 567 | } 568 | 569 | return $this; 570 | } 571 | 572 | protected function askToInstallEloquentDriver() 573 | { 574 | if (! $this->input->isInteractive()) { 575 | return $this; 576 | } 577 | 578 | $choice = select( 579 | label: 'Where do you want to store your content and data?', 580 | options: [ 581 | 'flat-file' => 'Flat Files', 582 | 'database' => 'Database', 583 | ], 584 | default : 'flat-file', 585 | hint: 'When in doubt, choose Flat Files. You can always change this later.' 586 | ); 587 | 588 | $this->shouldConfigureDatabase = $choice === 'database'; 589 | 590 | return $this; 591 | } 592 | 593 | protected function configureDatabaseConnection() 594 | { 595 | if (! $this->shouldConfigureDatabase) { 596 | return $this; 597 | } 598 | 599 | $database = $this->promptForDatabaseOptions(); 600 | 601 | $this->configureDefaultDatabaseConnection($database, $this->name); 602 | 603 | if ($database === 'sqlite') { 604 | touch($this->absolutePath.'/database/database.sqlite'); 605 | } 606 | 607 | $command = ['migrate']; 608 | 609 | if (! $this->input->isInteractive()) { 610 | $command[] = '--no-interaction'; 611 | } 612 | 613 | $migrate = (new Please($this->output)) 614 | ->cwd($this->absolutePath) 615 | ->run(...$command); 616 | 617 | // When there's an issue running the migrations, it's likely because of connection issues. 618 | // Let's let the user know and continue with the install process. 619 | if ($migrate !== 0) { 620 | $this->shouldConfigureDatabase = false; 621 | 622 | $this->output->write(' There was a problem connecting to the database. '.PHP_EOL); 623 | $this->output->write(PHP_EOL); 624 | $this->output->write(' Once the install process is complete, please run php please install:eloquent-driver to finish setting up the database.'.PHP_EOL); 625 | } 626 | 627 | return $this; 628 | } 629 | 630 | protected function installEloquentDriver() 631 | { 632 | if (! $this->shouldConfigureDatabase) { 633 | return $this; 634 | } 635 | 636 | $options = [ 637 | '--import', 638 | '--without-messages', 639 | ]; 640 | 641 | $whichRepositories = select( 642 | label: 'Do you want to store everything in the database, or just some things?', 643 | options: [ 644 | 'everything' => 'Everything', 645 | 'custom' => 'Let me choose', 646 | ], 647 | default: 'everything' 648 | ); 649 | 650 | if ($whichRepositories === 'everything') { 651 | $options[] = '--all'; 652 | } 653 | 654 | (new Please($this->output)) 655 | ->cwd($this->absolutePath) 656 | ->run('install:eloquent-driver', ...$options); 657 | 658 | $this->output->write(' [✔] Database setup complete!', PHP_EOL); 659 | 660 | return $this; 661 | } 662 | 663 | protected function askToInstallSsg() 664 | { 665 | if ($this->ssg || ! $this->input->isInteractive()) { 666 | return $this; 667 | } 668 | 669 | if (confirm('Do you plan to generate a static site?', default: false)) { 670 | $this->ssg = true; 671 | } 672 | 673 | return $this; 674 | } 675 | 676 | protected function installSsg() 677 | { 678 | if (! $this->ssg) { 679 | return $this; 680 | } 681 | 682 | $this->output->write(PHP_EOL); 683 | intro('Installing the Static Site Generator addon...'); 684 | 685 | $statusCode = (new Please($this->output)) 686 | ->cwd($this->absolutePath) 687 | ->run('install:ssg'); 688 | 689 | if ($statusCode !== 0) { 690 | throw new RuntimeException('There was a problem installing the Static Site Generator addon!'); 691 | } 692 | 693 | return $this; 694 | } 695 | 696 | protected function askToMakeSuperUser() 697 | { 698 | if ($this->input->getOption('email')) { 699 | $this->makeUser = true; 700 | 701 | return $this; 702 | } 703 | 704 | if (! $this->input->isInteractive()) { 705 | return $this; 706 | } 707 | 708 | $this->makeUser = confirm('Create a super user?', false); 709 | 710 | $this->output->write( 711 | $this->makeUser 712 | ? " Great. You'll be prompted for details after installation." 713 | : ' No problem. You can create one later with php please make:user.' 714 | ); 715 | 716 | $this->output->write(PHP_EOL.PHP_EOL); 717 | 718 | return $this; 719 | } 720 | 721 | /** 722 | * Make super user. 723 | * 724 | * @return $this 725 | */ 726 | protected function makeSuperUser() 727 | { 728 | if (! $this->makeUser) { 729 | return $this; 730 | } 731 | 732 | $email = $this->input->getOption('email'); 733 | $password = $this->input->getOption('password') ?? 'password'; 734 | 735 | if (! $email) { 736 | $this->output->write(PHP_EOL.PHP_EOL); 737 | intro("Let's create your super user account."); 738 | } 739 | 740 | // Since Windows cannot TTY, we'll capture their input here and make a user. 741 | if ($this->input->isInteractive() && ! $email && PHP_OS_FAMILY === 'Windows') { 742 | return $this->makeSuperUserInWindows(); 743 | } 744 | 745 | $command = ['make:user']; 746 | 747 | if ($email) { 748 | $command = [...$command, $email, '--password='.$password]; 749 | } 750 | 751 | $command[] = '--super'; 752 | 753 | if (! $this->input->isInteractive()) { 754 | $command[] = '--no-interaction'; 755 | } 756 | 757 | // Otherwise, delegate to the `make:user` command and let core handle the finer details. 758 | (new Please($this->output)) 759 | ->cwd($this->absolutePath) 760 | ->run(...$command); 761 | 762 | return $this; 763 | } 764 | 765 | /** 766 | * Make super user in Windows. 767 | * 768 | * @return $this 769 | */ 770 | protected function makeSuperUserInWindows() 771 | { 772 | $please = (new Please($this->output))->cwd($this->absolutePath); 773 | 774 | // Ask for email 775 | while (! isset($email) || ! $this->validateEmail($email)) { 776 | $email = $this->askForBasicInput('Email'); 777 | } 778 | 779 | // Ask for password 780 | while (! isset($password) || ! $this->validatePassword($password)) { 781 | $password = $this->askForBasicInput('Password (Your input will be hidden)', true); 782 | } 783 | 784 | // Create super user and update with captured input. 785 | $please->run('make:user', $email, '--password='.$password, '--super'); 786 | 787 | return $this; 788 | } 789 | 790 | /** 791 | * Ask to initialize a Git repository. 792 | * 793 | * @return $this 794 | */ 795 | protected function askToInitializeGitRepository() 796 | { 797 | if ( 798 | $this->initializeGitRepository 799 | || ! $this->isGitInstalled() 800 | || ! $this->input->isInteractive() 801 | ) { 802 | return $this; 803 | } 804 | 805 | $this->initializeGitRepository = confirm( 806 | label: 'Would you like to initialize a Git repository?', 807 | default: false 808 | ); 809 | 810 | return $this; 811 | } 812 | 813 | /** 814 | * Initialize a Git repository. 815 | * 816 | * @return $this 817 | */ 818 | protected function initializeGitRepository() 819 | { 820 | if (! $this->initializeGitRepository || ! $this->isGitInstalled()) { 821 | return $this; 822 | } 823 | 824 | $branch = $this->input->getOption('branch') ?: $this->defaultBranch(); 825 | 826 | $commands = [ 827 | 'git init -q', 828 | 'git add .', 829 | 'git commit -q -m "Set up a fresh Statamic site"', 830 | "git branch -M {$branch}", 831 | ]; 832 | 833 | $this->runCommands($commands, workingPath: $this->absolutePath); 834 | 835 | return $this; 836 | } 837 | 838 | /** 839 | * Check if Git is installed. 840 | */ 841 | protected function isGitInstalled(): bool 842 | { 843 | $process = new Process(['git', '--version']); 844 | 845 | $process->run(); 846 | 847 | return $process->isSuccessful(); 848 | } 849 | 850 | /** 851 | * Return the local machine's default Git branch if set or default to `main`. 852 | */ 853 | protected function defaultBranch(): string 854 | { 855 | $process = new Process(['git', 'config', '--global', 'init.defaultBranch']); 856 | $process->run(); 857 | 858 | $output = trim($process->getOutput()); 859 | 860 | return $process->isSuccessful() && $output ? $output : 'main'; 861 | } 862 | 863 | /** 864 | * Ask if the user wants to push the repository to GitHub. 865 | * 866 | * @return $this 867 | */ 868 | protected function askToPushToGithub() 869 | { 870 | if ( 871 | ! $this->initializeGitRepository 872 | || ! $this->isGitInstalled() 873 | || ! $this->isGhInstalled() 874 | || ! $this->input->isInteractive() 875 | ) { 876 | return $this; 877 | } 878 | 879 | if (! $this->shouldPushToGithub) { 880 | $this->shouldPushToGithub = confirm( 881 | label: 'Would you like to create a new repository on GitHub?', 882 | default: false 883 | ); 884 | 885 | if ($this->shouldPushToGithub && ! $this->githubRepository) { 886 | $this->githubRepository = text( 887 | label: 'What should be your full repository name?', 888 | default: $this->name, 889 | hint: "Use `yourorg/$this->name` to create a repo in your organization.", 890 | required: true, 891 | ); 892 | } 893 | 894 | if ($this->shouldPushToGithub && ! $this->repositoryVisibility) { 895 | $this->repositoryVisibility = select( 896 | label: 'Should the repository be public or private?', 897 | options: [ 898 | 'public' => 'Public', 899 | 'private' => 'Private', 900 | ], 901 | default: 'private', 902 | ); 903 | } 904 | } 905 | 906 | return $this; 907 | } 908 | 909 | /** 910 | * Create a GitHub repository and push the git log to it. 911 | * 912 | * @return $this 913 | */ 914 | protected function pushToGithub() 915 | { 916 | if (! $this->shouldPushToGithub) { 917 | return $this; 918 | } 919 | 920 | $name = $this->githubRepository ?? $this->name; 921 | $visibility = $this->repositoryVisibility ?? 'private'; 922 | 923 | $commands = [ 924 | "gh repo create {$name} --source=. --push --{$visibility}", 925 | ]; 926 | 927 | $this->runCommands($commands, $this->absolutePath, disableOutput: true); 928 | 929 | return $this; 930 | } 931 | 932 | /** 933 | * Check if GitHub's GH CLI tool is installed. 934 | */ 935 | protected function isGhInstalled(): bool 936 | { 937 | $process = new Process(['gh', 'auth', 'status']); 938 | 939 | $process->run(); 940 | 941 | return $process->isSuccessful(); 942 | } 943 | 944 | /** 945 | * Ask for basic input. 946 | * 947 | * @param string $label 948 | * @param bool $hiddenInput 949 | * @return mixed 950 | */ 951 | protected function askForBasicInput($label, $hiddenInput = false) 952 | { 953 | return $this->getHelper('question')->ask( 954 | $this->input, 955 | new SymfonyStyle($this->input, $this->output), 956 | (new Question("{$label}: "))->setHidden($hiddenInput) 957 | ); 958 | } 959 | 960 | /** 961 | * Validate email address. 962 | * 963 | * @param string $email 964 | * @return bool 965 | */ 966 | protected function validateEmail($email) 967 | { 968 | if (filter_var($email, FILTER_VALIDATE_EMAIL)) { 969 | return true; 970 | } 971 | 972 | $this->output->write('Invalid email address.'.PHP_EOL); 973 | 974 | return false; 975 | } 976 | 977 | /** 978 | * Validate password. 979 | * 980 | * @param string $password 981 | * @return bool 982 | */ 983 | protected function validatePassword($password) 984 | { 985 | if (strlen($password) >= 8) { 986 | return true; 987 | } 988 | 989 | $this->output->write('The input must be at least 8 characters.'.PHP_EOL); 990 | 991 | return false; 992 | } 993 | 994 | protected function askToEnableStatamicPro() 995 | { 996 | if ($this->input->getOption('pro') !== false || ! $this->input->isInteractive()) { 997 | return $this; 998 | } 999 | 1000 | $this->pro = confirm( 1001 | label: 'Do you want to enable Statamic Pro?', 1002 | default: true, 1003 | hint: 'Statamic Pro is required for some features. Like Multi-site, the Git integration, and more.' 1004 | ); 1005 | 1006 | if ($this->pro) { 1007 | $this->output->write(' Before your site goes live, you will need to purchase a license on statamic.com.'.PHP_EOL.PHP_EOL); 1008 | } 1009 | 1010 | return $this; 1011 | } 1012 | 1013 | protected function enableStatamicPro() 1014 | { 1015 | if (! $this->pro) { 1016 | return $this; 1017 | } 1018 | 1019 | $statusCode = (new Please($this->output)) 1020 | ->cwd($this->absolutePath) 1021 | ->run('pro:enable'); 1022 | 1023 | if ($statusCode !== 0) { 1024 | throw new RuntimeException('There was a problem enabling Statamic Pro!'); 1025 | } 1026 | 1027 | return $this; 1028 | } 1029 | 1030 | /** 1031 | * Show success message. 1032 | * 1033 | * @return $this 1034 | */ 1035 | protected function showSuccessMessage() 1036 | { 1037 | $this->output->writeln(PHP_EOL.' [✔] Statamic was installed successfully!'.PHP_EOL); 1038 | $this->output->writeln(' You may now enter your project directory using cd '.$this->relativePath.','.PHP_EOL); 1039 | $this->output->writeln(' The documentation is always available at statamic.dev and you can '); 1040 | $this->output->writeLn(' join the community on Discord at statamic.com/discord anytime.'.PHP_EOL); 1041 | $this->output->writeLn(' Now go — it\'s time to create something wonderful! 🌟'.PHP_EOL); 1042 | 1043 | return $this; 1044 | } 1045 | 1046 | /** 1047 | * Show cached post-install instructions, if provided. 1048 | * 1049 | * @return $this 1050 | */ 1051 | protected function showPostInstallInstructions() 1052 | { 1053 | if (! file_exists($instructionsPath = $this->absolutePath.'/storage/statamic/tmp/cli/post-install-instructions.txt')) { 1054 | return $this; 1055 | } 1056 | 1057 | $this->output->write(PHP_EOL); 1058 | 1059 | foreach (file($instructionsPath) as $line) { 1060 | $this->output->write(''.trim($line).''.PHP_EOL); 1061 | } 1062 | 1063 | return $this; 1064 | } 1065 | 1066 | /** 1067 | * Ask if user wants to star our GitHub repo. 1068 | * 1069 | * @return $this 1070 | */ 1071 | protected function askToSpreadJoy() 1072 | { 1073 | if (! $this->input->isInteractive()) { 1074 | return $this; 1075 | } 1076 | 1077 | $response = select('Would you like to spread the joy of Statamic by starring the repo?', [ 1078 | $yes = 'Absolutely', 1079 | $no = 'Maybe later', 1080 | ], $no); 1081 | 1082 | if ($response === $yes) { 1083 | if (PHP_OS_FAMILY == 'Darwin') { 1084 | exec('open https://github.com/statamic/cms'); 1085 | } elseif (PHP_OS_FAMILY == 'Windows') { 1086 | exec('start https://github.com/statamic/cms'); 1087 | } elseif (PHP_OS_FAMILY == 'Linux') { 1088 | exec('xdg-open https://github.com/statamic/cms'); 1089 | } 1090 | } else { 1091 | $this->output->write(' No problem. You can do it at github.com/statamic/cms anytime.'); 1092 | } 1093 | 1094 | $this->output->write(PHP_EOL.PHP_EOL); 1095 | 1096 | return $this; 1097 | } 1098 | 1099 | /** 1100 | * Check if the application path already exists. 1101 | * 1102 | * @return bool 1103 | */ 1104 | protected function applicationExists() 1105 | { 1106 | if ($this->pathIsCwd()) { 1107 | return is_file("{$this->absolutePath}/composer.json"); 1108 | } 1109 | 1110 | return is_dir($this->absolutePath) || is_file($this->absolutePath); 1111 | } 1112 | 1113 | /** 1114 | * Check if the application path is the current working directory. 1115 | * 1116 | * @return bool 1117 | */ 1118 | protected function pathIsCwd() 1119 | { 1120 | return $this->absolutePath === getcwd(); 1121 | } 1122 | 1123 | /** 1124 | * Check if the starter kit is invalid. 1125 | * 1126 | * @return bool 1127 | */ 1128 | protected function isInvalidStarterKit() 1129 | { 1130 | return ! preg_match("/^[^\/\s]+\/[^\/\s]+$/", $this->starterKit); 1131 | } 1132 | 1133 | /** 1134 | * Determine if base install was successful. 1135 | * 1136 | * @return bool 1137 | */ 1138 | protected function wasBaseInstallSuccessful() 1139 | { 1140 | return is_file("{$this->absolutePath}/composer.json") 1141 | && is_dir("{$this->absolutePath}/vendor") 1142 | && is_file("{$this->absolutePath}/artisan") 1143 | && is_file("{$this->absolutePath}/please"); 1144 | } 1145 | 1146 | /** 1147 | * Create the composer create-project command. 1148 | * 1149 | * @return string 1150 | */ 1151 | protected function createProjectCommand() 1152 | { 1153 | $composer = $this->findComposer(); 1154 | 1155 | $baseRepo = self::BASE_REPO; 1156 | 1157 | $directory = $this->pathIsCwd() ? '.' : $this->relativePath; 1158 | 1159 | return $composer." create-project {$baseRepo} \"{$directory}\" {$this->version} --remove-vcs --prefer-dist"; 1160 | } 1161 | 1162 | /** 1163 | * Get the composer command for the environment. 1164 | * 1165 | * @return string 1166 | */ 1167 | protected function findComposer() 1168 | { 1169 | $composerPath = getcwd().'/composer.phar'; 1170 | 1171 | if (file_exists($composerPath)) { 1172 | return '"'.PHP_BINARY.'" '.$composerPath; 1173 | } 1174 | 1175 | return 'composer'; 1176 | } 1177 | 1178 | /** 1179 | * Replace the given string in the given file. 1180 | */ 1181 | protected function replaceInFile(string|array $search, string|array $replace, string $file): void 1182 | { 1183 | file_put_contents( 1184 | $file, 1185 | str_replace($search, $replace, file_get_contents($file)) 1186 | ); 1187 | } 1188 | 1189 | /** 1190 | * Replace the given string in the given file using regular expressions. 1191 | */ 1192 | protected function pregReplaceInFile(string $pattern, string $replace, string $file): void 1193 | { 1194 | file_put_contents( 1195 | $file, 1196 | preg_replace($pattern, $replace, file_get_contents($file)) 1197 | ); 1198 | } 1199 | 1200 | /** 1201 | * Throw guzzle connection exception. 1202 | * 1203 | * @throws RuntimeException 1204 | */ 1205 | protected function throwConnectionException() 1206 | { 1207 | throw new RuntimeException('Cannot connect to [statamic.com] to validate license. Please try again later.'); 1208 | } 1209 | 1210 | /** 1211 | * Get starter kit license from parsed options, or ask user for license. 1212 | * 1213 | * @return string 1214 | */ 1215 | protected function getStarterKitLicense() 1216 | { 1217 | if ($this->starterKitLicense) { 1218 | return $this->starterKitLicense; 1219 | } 1220 | 1221 | if (! $this->input->isInteractive()) { 1222 | throw new RuntimeException('A starter kit license is required, please pass using the `--license` option!'); 1223 | } 1224 | 1225 | return text('Please enter your license key', required: true); 1226 | } 1227 | 1228 | private function searchStarterKits($value) 1229 | { 1230 | $kits = $this->getStarterKits(); 1231 | 1232 | return array_filter($kits, fn ($kit) => str_contains(strtolower($kit), strtolower($value))); 1233 | } 1234 | 1235 | private function getStarterKits() 1236 | { 1237 | return $this->starterKits ??= $this->fetchStarterKits(); 1238 | } 1239 | 1240 | private function fetchStarterKits() 1241 | { 1242 | $request = new Client(['base_uri' => self::STATAMIC_API_URL]); 1243 | 1244 | try { 1245 | $response = $request->get('marketplace/starter-kits', ['query' => ['perPage' => 100]]); 1246 | $results = json_decode($response->getBody(), true)['data']; 1247 | $options = []; 1248 | 1249 | foreach ($results as $value) { 1250 | $options[$value['package']] = $value['name'].' ('.$value['package'].')'; 1251 | } 1252 | 1253 | return $options; 1254 | } catch (\Exception $e) { 1255 | return []; 1256 | } 1257 | } 1258 | 1259 | private function normalizeStarterKitSelection($kit) 1260 | { 1261 | // If it doesn't have a bracket it means they manually entered a value and didn't pick a suggestion. 1262 | if (! str_contains($kit, ' (')) { 1263 | return $kit; 1264 | } 1265 | 1266 | return array_search($kit, $this->getStarterKits()); 1267 | } 1268 | 1269 | private function showError(string $message): void 1270 | { 1271 | $whitespace = ''; 1272 | 1273 | for ($i = 0; $i < strlen($message); $i++) { 1274 | $whitespace .= ' '; 1275 | } 1276 | 1277 | $this->output->write(PHP_EOL); 1278 | $this->output->write(" {$whitespace} ".PHP_EOL); 1279 | $this->output->write(" {$message} ".PHP_EOL); 1280 | $this->output->write(" {$whitespace} ".PHP_EOL); 1281 | $this->output->write(PHP_EOL); 1282 | } 1283 | } 1284 | -------------------------------------------------------------------------------- /src/Please.php: -------------------------------------------------------------------------------- 1 | output = $output; 20 | } 21 | 22 | /** 23 | * Get or set current working directory. 24 | * 25 | * @param mixed $cwd 26 | * @return mixed 27 | */ 28 | public function cwd($cwd = null) 29 | { 30 | if (func_num_args() === 0) { 31 | return $this->cwd ?? getcwd(); 32 | } 33 | 34 | $this->cwd = $cwd; 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * Check if Statamic instance is v2. 41 | * 42 | * @return bool 43 | */ 44 | public function isV2() 45 | { 46 | return is_dir($this->cwd().'/statamic') && is_file($this->cwd().'/please'); 47 | } 48 | 49 | /** 50 | * Run please command. 51 | * 52 | * @param mixed $commandParts 53 | * @return int 54 | */ 55 | public function run(...$commandParts) 56 | { 57 | if (! is_file($this->cwd().'/please')) { 58 | throw new \RuntimeException('This does not appear to be a Statamic project.'); 59 | } 60 | 61 | $process = (new Process(array_merge([PHP_BINARY, 'please'], $commandParts))) 62 | ->setTimeout(null); 63 | 64 | if ($this->cwd) { 65 | $process->setWorkingDirectory($this->cwd); 66 | } 67 | 68 | try { 69 | $process->setTty(true); 70 | } catch (RuntimeException $e) { 71 | // TTY not supported. Move along. 72 | } 73 | 74 | $process->run(function ($type, $line) { 75 | $this->output->write($line); 76 | }); 77 | 78 | return $process->getExitCode(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Theme/ConfirmPromptRenderer.php: -------------------------------------------------------------------------------- 1 | teal($text); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Theme/SelectPromptRenderer.php: -------------------------------------------------------------------------------- 1 | teal($text); 12 | } 13 | 14 | public function teal(string $text): string 15 | { 16 | $color = Terminal::getColorMode()->convertFromHexToAnsiColorCode('01D7B0'); 17 | 18 | return "\e[3{$color}m{$text}\e[39m"; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Theme/TextPromptRenderer.php: -------------------------------------------------------------------------------- 1 | setName('update') 27 | ->setDescription('Update the current directory\'s Statamic install to the latest version'); 28 | } 29 | 30 | /** 31 | * Execute the command. 32 | */ 33 | protected function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $this->input = $input; 36 | $this->output = $output; 37 | 38 | $please = new Please($output); 39 | 40 | if ($please->isV2()) { 41 | $output->writeln(PHP_EOL.'Statamic v2 is no longer supported!'.PHP_EOL); 42 | 43 | return 1; 44 | } 45 | 46 | $output->writeln(PHP_EOL.'NOTE: If you have previously updated using the CP, you may need to update the version in your composer.json before running this update!'.PHP_EOL); 47 | 48 | $command = $this->updateCommand(); 49 | 50 | $this->runCommand($command); 51 | 52 | return 0; 53 | } 54 | 55 | /** 56 | * Determine the update command. 57 | * 58 | * @return string 59 | */ 60 | protected function updateCommand() 61 | { 62 | $helper = $this->getHelper('question'); 63 | 64 | $options = [ 65 | 'Update Statamic and its dependencies [composer update statamic/cms --with-dependencies]', 66 | 'Update all project dependencies [composer update]', 67 | ]; 68 | 69 | $question = new ChoiceQuestion('How would you like to update Statamic?', $options, 0); 70 | 71 | $selection = $helper->ask($this->input, new SymfonyStyle($this->input, $this->output), $question); 72 | 73 | return strpos($selection, 'statamic/cms --with-dependencies') 74 | ? 'composer update statamic/cms --with-dependencies' 75 | : 'composer update'; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Version.php: -------------------------------------------------------------------------------- 1 | setName('version') 20 | ->setDescription('Get the version of Statamic installed in the current directory'); 21 | } 22 | 23 | /** 24 | * Execute the command. 25 | */ 26 | protected function execute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $please = new Please($output); 29 | 30 | if ($please->isV2()) { 31 | $please->run('version'); 32 | } else { 33 | $please->run('--version'); 34 | } 35 | 36 | return 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests-output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tests/NewCommandIntegrationTest.php: -------------------------------------------------------------------------------- 1 | scaffoldName = 'tests-output/my-app'; 21 | $this->scaffoldDirectory = __DIR__.'/../'.$this->scaffoldName; 22 | 23 | $this->clearScaffoldDirectory(); 24 | } 25 | 26 | public function tearDown(): void 27 | { 28 | $this->clearScaffoldDirectory(); 29 | 30 | parent::tearDown(); 31 | } 32 | 33 | /** @test */ 34 | public function it_can_scaffold_a_new_statamic_app() 35 | { 36 | $this->assertAppNotExists(); 37 | 38 | $statusCode = $this->scaffoldNewApp(); 39 | 40 | $this->assertSame(0, $statusCode); 41 | $this->assertBasicAppScaffolded(); 42 | } 43 | 44 | /** @test */ 45 | public function it_can_scaffold_a_new_statamic_app_with_a_starter_kit() 46 | { 47 | $this->assertAppNotExists(); 48 | 49 | $statusCode = $this->scaffoldNewApp(['starter-kit' => 'statamic/starter-kit-cool-writings']); 50 | 51 | $this->assertSame(0, $statusCode); 52 | $this->assertBasicAppScaffolded(); 53 | $this->assertFileExists($this->appPath('content/collections/articles/1994-07-05.magic.md')); 54 | $this->assertFileExists($this->appPath('resources/blueprints/collections/articles/article.yaml')); 55 | $this->assertFileNotExists($this->appPath('starter-kit.yaml')); 56 | } 57 | 58 | /** @test */ 59 | public function it_can_scaffold_with_starter_kit_config() 60 | { 61 | $this->assertAppNotExists(); 62 | 63 | $statusCode = $this->scaffoldNewApp(['starter-kit' => 'statamic/starter-kit-cool-writings', '--with-config' => true]); 64 | 65 | $this->assertSame(0, $statusCode); 66 | $this->assertBasicAppScaffolded(); 67 | $this->assertFileExists($this->appPath('content/collections/articles/1994-07-05.magic.md')); 68 | $this->assertFileExists($this->appPath('resources/blueprints/collections/articles/article.yaml')); 69 | $this->assertFileExists($this->appPath('starter-kit.yaml')); 70 | } 71 | 72 | /** @test */ 73 | public function it_fails_if_application_folder_already_exists() 74 | { 75 | mkdir($this->appPath()); 76 | 77 | $this->assertRuntimeException(function () { 78 | $this->scaffoldNewApp(); 79 | }); 80 | 81 | $this->assertFileExists($this->appPath()); 82 | $this->assertFileNotExists($this->appPath('vendor')); 83 | $this->assertFileNotExists($this->appPath('.env')); 84 | $this->assertFileNotExists($this->appPath('artisan')); 85 | $this->assertFileNotExists($this->appPath('please')); 86 | } 87 | 88 | /** @test */ 89 | public function it_overwrites_application_when_using_force_option() 90 | { 91 | mkdir($this->appPath()); 92 | file_put_contents($this->appPath('test.md'), 'test content'); 93 | 94 | $this->assertFileExists($this->appPath('test.md')); 95 | 96 | $this->scaffoldNewApp(['--force' => true]); 97 | 98 | $this->assertBasicAppScaffolded(); 99 | $this->assertFileNotExists($this->appPath('test.md')); 100 | } 101 | 102 | /** @test */ 103 | public function it_fails_if_using_force_option_to_cwd() 104 | { 105 | $this->assertRuntimeException(function () { 106 | $this->scaffoldNewApp(['name' => '.', '--force' => true]); 107 | }); 108 | 109 | $this->assertAppNotExists(); 110 | } 111 | 112 | /** @test */ 113 | public function it_fails_if_invalid_starter_kit_repo_is_passed() 114 | { 115 | $this->assertRuntimeException(function () { 116 | $this->scaffoldNewApp(['starter-kit' => 'not-a-valid-repo']); 117 | }); 118 | 119 | $this->assertAppNotExists(); 120 | } 121 | 122 | /** @test */ 123 | public function it_fails_when_there_is_starter_kit_error_but_leaves_base_installation() 124 | { 125 | $this->assertRuntimeException(function () { 126 | $this->scaffoldNewApp(['starter-kit' => 'statamic/not-an-actual-starter-kit']); 127 | }); 128 | 129 | $this->assertBasicAppScaffolded(); 130 | } 131 | 132 | protected function assertRuntimeException($callback) 133 | { 134 | $error = false; 135 | 136 | try { 137 | $callback(); 138 | } catch (RuntimeException $exception) { 139 | $error = true; 140 | } 141 | 142 | $this->assertTrue($error); 143 | } 144 | 145 | protected function clearScaffoldDirectory() 146 | { 147 | if (file_exists($this->scaffoldDirectory)) { 148 | if (PHP_OS_FAMILY == 'Windows') { 149 | exec("rd /s /q \"$this->scaffoldDirectory\""); 150 | } else { 151 | exec("rm -rf \"$this->scaffoldDirectory\""); 152 | } 153 | } 154 | } 155 | 156 | protected function appPath($path = null) 157 | { 158 | if ($path) { 159 | return $this->scaffoldDirectory.'/'.$path; 160 | } 161 | 162 | return $this->scaffoldDirectory; 163 | } 164 | 165 | protected function scaffoldNewApp($args = []) 166 | { 167 | $app = new Application('Statamic Installer'); 168 | 169 | $app->add(new NewCommand); 170 | 171 | $tester = new CommandTester($app->find('new')); 172 | 173 | $args = array_merge(['name' => $this->scaffoldName], $args); 174 | 175 | $statusCode = $tester->execute($args); 176 | 177 | return $statusCode; 178 | } 179 | 180 | protected function assertBasicAppScaffolded() 181 | { 182 | $this->assertFileExists($this->appPath('vendor')); 183 | $this->assertFileExists($this->appPath('.env')); 184 | $this->assertFileExists($this->appPath('artisan')); 185 | $this->assertFileExists($this->appPath('please')); 186 | 187 | $envFile = file_get_contents($this->appPath('.env')); 188 | $this->assertStringContainsString('APP_URL=http://my-app.test', $envFile); 189 | $this->assertStringContainsString('DB_DATABASE=my_app', $envFile); 190 | } 191 | 192 | protected function assertAppNotExists() 193 | { 194 | $this->assertFileNotExists($this->appPath()); 195 | } 196 | } 197 | --------------------------------------------------------------------------------