diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a8adb80..f28548a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.3, 7.4, '8.0', 8.1, 8.2] + php: [8.1, 8.2] name: PHP ${{ matrix.php }} @@ -44,7 +44,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.3, 7.4, '8.0', 8.1, 8.2] + php: [8.1, 8.2] name: PHP ${{ matrix.php }} - Windows diff --git a/composer.json b/composer.json index 09c50daf..de12bea9 100644 --- a/composer.json +++ b/composer.json @@ -10,13 +10,14 @@ } ], "require": { - "php": "^7.3|^8.0", - "symfony/console": "^4.0|^5.0|^6.0", - "symfony/process": "^4.2|^5.0|^6.0" + "php": "^8.1", + "laravel/prompts": "^0.1", + "symfony/console": "^6.0", + "symfony/process": "^6.0" }, "require-dev": { "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.0|^9.3" + "phpunit/phpunit": "^9.3" }, "bin": [ "bin/laravel" diff --git a/src/Concerns/ConfiguresPrompts.php b/src/Concerns/ConfiguresPrompts.php new file mode 100644 index 00000000..6cb7570e --- /dev/null +++ b/src/Concerns/ConfiguresPrompts.php @@ -0,0 +1,132 @@ +isInteractive() || PHP_OS_FAMILY === 'Windows'); + + TextPrompt::fallbackUsing(fn (TextPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($input, $output))->ask($prompt->label, $prompt->default ?: null) ?? '', + $prompt->required, + $prompt->validate, + $output + )); + + PasswordPrompt::fallbackUsing(fn (PasswordPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($input, $output))->askHidden($prompt->label) ?? '', + $prompt->required, + $prompt->validate, + $output + )); + + ConfirmPrompt::fallbackUsing(fn (ConfirmPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($input, $output))->confirm($prompt->label, $prompt->default), + $prompt->required, + $prompt->validate, + $output + )); + + SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid( + fn () => (new SymfonyStyle($input, $output))->choice($prompt->label, $prompt->options, $prompt->default), + false, + $prompt->validate, + $output + )); + + MultiSelectPrompt::fallbackUsing(function (MultiSelectPrompt $prompt) use ($input, $output) { + if ($prompt->default !== []) { + return $this->promptUntilValid( + fn () => (new SymfonyStyle($input, $output))->choice($prompt->label, $prompt->options, implode(',', $prompt->default), true), + $prompt->required, + $prompt->validate, + $output + ); + } + + return $this->promptUntilValid( + fn () => collect((new SymfonyStyle($input, $output))->choice( + $prompt->label, + array_is_list($prompt->options) + ? ['None', ...$prompt->options] + : ['none' => 'None', ...$prompt->options], + 'None', + true) + )->reject(array_is_list($prompt->options) ? 'None' : 'none')->all(), + $prompt->required, + $prompt->validate, + $output + ); + }); + + SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid( + function () use ($prompt, $input, $output) { + $question = new Question($prompt->label, $prompt->default); + + is_callable($prompt->options) + ? $question->setAutocompleterCallback($prompt->options) + : $question->setAutocompleterValues($prompt->options); + + return (new SymfonyStyle($input, $output))->askQuestion($question); + }, + $prompt->required, + $prompt->validate, + $output + )); + } + + /** + * Prompt the user until the given validation callback passes. + * + * @param \Closure $prompt + * @param bool|string $required + * @param \Closure|null $validate + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return mixed + */ + protected function promptUntilValid($prompt, $required, $validate, $output) + { + while (true) { + $result = $prompt(); + + if ($required && ($result === '' || $result === [] || $result === false)) { + $output->writeln(''.(is_string($required) ? $required : 'Required.').''); + + continue; + } + + if ($validate) { + $error = $validate($result); + + if (is_string($error) && strlen($error) > 0) { + $output->writeln("{$error}"); + + continue; + } + } + + return $result; + } + } +} diff --git a/src/NewCommand.php b/src/NewCommand.php index d915e008..3c33cf9e 100644 --- a/src/NewCommand.php +++ b/src/NewCommand.php @@ -8,12 +8,17 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ChoiceQuestion; -use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Process\Process; +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\select; +use function Laravel\Prompts\text; + class NewCommand extends Command { + use Concerns\ConfiguresPrompts; + /** * Configure the command options. * @@ -38,78 +43,85 @@ protected function configure() ->addOption('teams', null, InputOption::VALUE_NONE, 'Indicates whether Jetstream should be scaffolded with team support') ->addOption('pest', null, InputOption::VALUE_NONE, 'Installs the Pest testing framework') ->addOption('phpunit', null, InputOption::VALUE_NONE, 'Installs the PHPUnit testing framework') - ->addOption('prompt-breeze', null, InputOption::VALUE_NONE, 'Issues a prompt to determine if Breeze should be installed') - ->addOption('prompt-jetstream', null, InputOption::VALUE_NONE, 'Issues a prompt to determine if Jetstream should be installed') + ->addOption('prompt-breeze', null, InputOption::VALUE_NONE, 'Issues a prompt to determine if Breeze should be installed (Deprecated)') + ->addOption('prompt-jetstream', null, InputOption::VALUE_NONE, 'Issues a prompt to determine if Jetstream should be installed (Deprecated)') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces install even if the directory already exists'); } /** - * Execute the command. + * Interact with the user before validating the input. * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return int + * @return void */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function interact(InputInterface $input, OutputInterface $output) { - $installBreeze = $input->getOption('breeze') || - ($input->getOption('prompt-breeze') && (new SymfonyStyle($input, $output))->confirm('Would you like to install the Laravel Breeze application scaffolding?', false)); - - $installJetstream = $input->getOption('jet') || - ($input->getOption('prompt-jetstream') && (new SymfonyStyle($input, $output))->confirm('Would you like to install the Laravel Jetstream application scaffolding?', false)); - - if ($installBreeze) { - $output->write(" ____ - | __ ) _ __ ___ ___ _______ - | _ \| '__/ _ \/ _ \_ / _ \ - | |_) | | | __/ __// / __/ - |____/|_| \___|\___/___\___|".PHP_EOL.PHP_EOL); - - $stack = $this->breezeStack($input, $output); - $testingFramework = $this->testingFramework($input, $output); + parent::interact($input, $output); - $dark = false; + $this->configurePrompts($input, $output); - if (in_array($stack, ['blade', 'vue', 'react'])) { - $dark = $input->getOption('dark') === true - ? (bool) $input->getOption('dark') - : (new SymfonyStyle($input, $output))->confirm('Would you like to install dark mode support?', false); - } - - $ssr = false; - - if (in_array($stack, ['vue', 'react'])) { - $ssr = $input->getOption('ssr') === true - ? (bool) $input->getOption('ssr') - : (new SymfonyStyle($input, $output))->confirm('Would you like to install Inertia SSR support?', false); - } - } elseif ($installJetstream) { - $output->write(PHP_EOL." - | | | - |,---.|--- ,---.|--- ,---.,---.,---.,-.-. - ||---'| `---.| | |---',---|| | | - `---'`---'`---'`---'`---'` `---'`---^` ' '".PHP_EOL.PHP_EOL); - - $stack = $this->jetstreamStack($input, $output); - $testingFramework = $this->testingFramework($input, $output); - - $teams = $input->getOption('teams') === true - ? (bool) $input->getOption('teams') - : (new SymfonyStyle($input, $output))->confirm('Will your application use teams?', false); - - $dark = $input->getOption('dark') === true - ? (bool) $input->getOption('dark') - : (new SymfonyStyle($input, $output))->confirm('Would you like to install dark mode support?', false); - } else { - $output->write(PHP_EOL.' _ _ + $output->write(PHP_EOL.' _ _ | | | | | | __ _ _ __ __ ___ _____| | | | / _` | \'__/ _` \ \ / / _ \ | | |___| (_| | | | (_| |\ V / __/ | |______\__,_|_| \__,_| \_/ \___|_|'.PHP_EOL.PHP_EOL); + + if (! $input->getArgument('name')) { + $input->setArgument('name', text( + label: 'What is the name of your project?', + placeholder: 'E.g. example-app', + required: 'The project name is required.', + validate: fn ($value) => preg_match('/[^\pL\pN\-_.]/', $value) !== 0 + ? 'The name may only contain letters, numbers, dashes, underscores, and periods.' + : null, + )); + } + + if (! $input->getOption('breeze') && ! $input->getOption('jet')) { + match (select( + label: 'Would you like to install a starter kit?', + options: [ + 'none' => 'No starter kit', + 'breeze' => 'Laravel Breeze', + 'jetstream' => 'Laravel Jetstream', + ], + )) { + 'breeze' => $input->setOption('breeze', true), + 'jetstream' => $input->setOption('jet', true), + default => null, + }; } - sleep(1); + if ($input->getOption('breeze')) { + $this->promptForBreezeOptions($input); + } elseif ($input->getOption('jet')) { + $this->promptForJetstreamOptions($input); + } + + if (! $input->getOption('phpunit') && ! $input->getOption('pest')) { + $input->setOption('pest', select( + label: 'Which testing framework do you prefer?', + options: ['PHPUnit', 'Pest'], + ) === 'Pest'); + } + + if (! $input->getOption('git') && Process::fromShellCommandline('git --version')->run() === 0) { + $input->setOption('git', confirm(label: 'Would you like to initialize a Git repository?')); + } + } + + /** + * Execute the command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->validateStackOption($input); $name = $input->getArgument('name'); @@ -168,10 +180,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->createRepository($directory, $input, $output); } - if ($installBreeze) { - $this->installBreeze($directory, $stack, $testingFramework, $dark, $ssr, $input, $output); - } elseif ($installJetstream) { - $this->installJetstream($directory, $stack, $testingFramework, $teams, $dark, $input, $output); + if ($input->getOption('breeze')) { + $this->installBreeze($directory, $input, $output); + } elseif ($input->getOption('jet')) { + $this->installJetstream($directory, $input, $output); } elseif ($input->getOption('pest')) { $this->installPest($directory, $input, $output); } @@ -207,15 +219,11 @@ protected function defaultBranch() * Install Laravel Breeze into the application. * * @param string $directory - * @param string $stack - * @param string $testingFramework - * @param bool $dark - * @param bool $ssr * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output * @return void */ - protected function installBreeze(string $directory, string $stack, string $testingFramework, bool $dark, bool $ssr, InputInterface $input, OutputInterface $output) + protected function installBreeze(string $directory, InputInterface $input, OutputInterface $output) { chdir($directory); @@ -223,10 +231,10 @@ protected function installBreeze(string $directory, string $stack, string $testi $this->findComposer().' require laravel/breeze', trim(sprintf( '"'.PHP_BINARY.'" artisan breeze:install %s %s %s %s', - $stack, - $testingFramework == 'pest' ? '--pest' : '', - $dark ? '--dark' : '', - $ssr ? '--ssr' : '', + $input->getOption('stack'), + $input->getOption('pest') ? '--pest' : '', + $input->getOption('dark') ? '--dark' : '', + $input->getOption('ssr') ? '--ssr' : '', )), ]); @@ -239,15 +247,11 @@ protected function installBreeze(string $directory, string $stack, string $testi * Install Laravel Jetstream into the application. * * @param string $directory - * @param string $stack - * @param string $testingFramework - * @param bool $teams - * @param bool $dark * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output * @return void */ - protected function installJetstream(string $directory, string $stack, string $testingFramework, bool $teams, bool $dark, InputInterface $input, OutputInterface $output) + protected function installJetstream(string $directory, InputInterface $input, OutputInterface $output) { chdir($directory); @@ -255,10 +259,10 @@ protected function installJetstream(string $directory, string $stack, string $te $this->findComposer().' require laravel/jetstream', trim(sprintf( '"'.PHP_BINARY.'" artisan jetstream:install %s %s %s %s', - $stack, - $teams ? '--teams' : '', - $dark ? '--dark' : '', - $testingFramework == 'pest' ? '--pest' : '', + $input->getOption('stack'), + $input->getOption('teams') ? '--teams' : '', + $input->getOption('dark') ? '--dark' : '', + $input->getOption('pest') ? '--pest' : '', )), ]); @@ -271,87 +275,94 @@ protected function installJetstream(string $directory, string $stack, string $te * Determine the stack for Breeze. * * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return string + * @return void */ - protected function breezeStack(InputInterface $input, OutputInterface $output) + protected function promptForBreezeOptions(InputInterface $input) { - $stacks = [ - 'blade', - 'react', - 'vue', - 'api', - ]; - - if ($input->getOption('stack') && in_array($input->getOption('stack'), $stacks)) { - return $input->getOption('stack'); + if (! $input->getOption('stack')) { + $input->setOption('stack', select( + label: 'Which Breeze stack would you like to install?', + options: [ + 'blade' => 'Blade', + 'react' => 'React with Inertia', + 'vue' => 'Vue with Inertia', + 'api' => 'API only', + ] + )); } - $helper = $this->getHelper('question'); - - $question = new ChoiceQuestion('Which Breeze stack do you prefer?', $stacks); - - $output->write(PHP_EOL); - - return $helper->ask($input, new SymfonyStyle($input, $output), $question); + if (in_array($input->getOption('stack'), ['react', 'vue']) && (! $input->getOption('dark') || ! $input->getOption('ssr'))) { + collect(multiselect( + label: 'Would you like any optional features?', + options: [ + 'dark' => 'Dark mode', + 'ssr' => 'Inertia SSR', + ], + default: array_filter([ + $input->getOption('dark') ? 'dark' : null, + $input->getOption('ssr') ? 'ssr' : null, + ]), + ))->each(fn ($option) => $input->setOption($option, true)); + } elseif ($input->getOption('stack') === 'blade' && ! $input->getOption('dark')) { + $input->setOption('dark', confirm( + label: 'Would you like dark mode support?', + default: false, + )); + } } /** * Determine the stack for Jetstream. * * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return string + * @return void */ - protected function jetstreamStack(InputInterface $input, OutputInterface $output) + protected function promptForJetstreamOptions(InputInterface $input) { - $stacks = [ - 'livewire', - 'inertia', - ]; - - if ($input->getOption('stack') && in_array($input->getOption('stack'), $stacks)) { - return $input->getOption('stack'); + if (! $input->getOption('stack')) { + $input->setOption('stack', select( + label: 'Which Jetstream stack would you like to install?', + options: [ + 'inertia' => 'Vue with Inertia', + 'livewire' => 'Livewire', + ] + )); } - $helper = $this->getHelper('question'); - - $question = new ChoiceQuestion('Which Jetstream stack do you prefer?', $stacks); - - $output->write(PHP_EOL); - - return $helper->ask($input, new SymfonyStyle($input, $output), $question); + collect(multiselect( + label: 'Would you like any optional features?', + options: collect([ + 'teams' => 'Team support', + 'dark' => 'Dark mode', + ])->when( + $input->getOption('stack') === 'inertia', + fn ($options) => $options->put('ssr', 'Inertia SSR') + )->all(), + default: array_filter([ + $input->getOption('teams') ? 'teams' : null, + $input->getOption('dark') ? 'dark' : null, + $input->getOption('stack') === 'inertia' && $input->getOption('ssr') ? 'ssr' : null, + ]), + ))->each(fn ($option) => $input->setOption($option, true)); } - /** - * Determine the testing framework for Jetstream. - * - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @return string - */ - protected function testingFramework(InputInterface $input, OutputInterface $output) + protected function validateStackOption(InputInterface $input) { - if ($input->getOption('pest')) { - return 'pest'; - } + if ($input->getOption('breeze')) { + if (! in_array($input->getOption('stack'), $stacks = ['blade', 'react', 'vue', 'api'])) { + throw new \InvalidArgumentException("Invalid Breeze stack [{$input->getOption('stack')}]. Valid options are: ".implode(', ', $stacks).'.'); + } - if ($input->getOption('phpunit')) { - return 'phpunit'; + return; } - $testingFrameworks = [ - 'pest', - 'phpunit', - ]; - - $helper = $this->getHelper('question'); - - $question = new ChoiceQuestion('Which testing framework do you prefer?', $testingFrameworks); - - $output->write(PHP_EOL); + if ($input->getOption('jet')) { + if (! in_array($input->getOption('stack'), $stacks = ['inertia', 'livewire'])) { + throw new \InvalidArgumentException("Invalid Jetstream stack [{$input->getOption('stack')}]. Valid options are: ".implode(', ', $stacks).'.'); + } - return $helper->ask($input, new SymfonyStyle($input, $output), $question); + return; + } } /** diff --git a/tests/NewCommandTest.php b/tests/NewCommandTest.php index 52c5939d..4ed37aed 100644 --- a/tests/NewCommandTest.php +++ b/tests/NewCommandTest.php @@ -27,7 +27,7 @@ public function test_it_can_scaffold_a_new_laravel_app() $tester = new CommandTester($app->find('new')); - $statusCode = $tester->execute(['name' => $scaffoldDirectoryName]); + $statusCode = $tester->execute(['name' => $scaffoldDirectoryName], ['interactive' => false]); $this->assertSame(0, $statusCode); $this->assertDirectoryExists($scaffoldDirectory.'/vendor');