├── .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 |
--------------------------------------------------------------------------------