diff --git a/.github/workflows/pr-welcome.yml b/.github/workflows/pr-welcome.yml new file mode 100644 index 0000000..d4cf8c0 --- /dev/null +++ b/.github/workflows/pr-welcome.yml @@ -0,0 +1,37 @@ +name: PR Welcome & Assign + +on: + pull_request: + types: [opened] + +jobs: + welcome-and-assign: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - name: Get PR Author + id: pr_author + run: echo "AUTHOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV + + - name: 'Auto-assign issue' + uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: HichemTab-tech + numOfAssignee: 1 + + - name: Comment on PR (if not the maintainer) + #if: env.AUTHOR != 'HichemTab-tech' + uses: actions/github-script@v6 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "👋 Thank you @${{ env.AUTHOR }} for your contribution! 🎉\n\nYour PR has been received and is awaiting review. Please be patient while we check it. 🚀" + }); \ No newline at end of file diff --git a/bin/laravelfs b/bin/laravelfs index a9f3046..b089dfc 100644 --- a/bin/laravelfs +++ b/bin/laravelfs @@ -9,7 +9,7 @@ if (file_exists(__DIR__.'/../../../autoload.php')) { } // Define our own version and the Laravel Installer version we aim to match. -$laravelFSVersion = '1.0.0'; +$laravelFSVersion = '2.0.0'; $laravelInstallerVersion = '5.13.0'; // Compose the displayed version string. @@ -17,6 +17,9 @@ $displayVersion = sprintf('%s (advanced from Laravel Installer %s)', $laravelFSV $app = new Symfony\Component\Console\Application('LaravelFS Installer', $displayVersion); $app->add(new HichemTabTech\LaravelFS\Console\NewCommand); +$app->add(new HichemTabTech\LaravelFS\Console\NewTemplateCommand); +$app->add(new HichemTabTech\LaravelFS\Console\ShowTemplatesCommand); +$app->add(new HichemTabTech\LaravelFS\Console\UseTemplateCommand); /** @noinspection PhpUnhandledExceptionInspection */ $app->run(); diff --git a/src/Concerns/CommandsUtils.php b/src/Concerns/CommandsUtils.php new file mode 100644 index 0000000..76d971e --- /dev/null +++ b/src/Concerns/CommandsUtils.php @@ -0,0 +1,162 @@ +getGlobalTemplatesPath(); + $noTemplates = false; + + // Check if the template file exists + if (!file_exists($configPath)) { + $noTemplates = true; + } + + if (!$noTemplates) { + // Load the templates + $templatesConfig = json_decode(file_get_contents($configPath), true); + $templates = []; + + if (empty($templatesConfig)) { + $noTemplates = true; + } + elseif (empty($templatesConfig['templates'])) { + $noTemplates = true; + } + else { + $templates = $templatesConfig['templates']; + } + + } + + if ($noTemplates) { + if (!$noInteract) { + error('No templates found. Create one using `laravelfs template:new `'); + } + return [ + 'templates' => [], + 'path' => $configPath, + ]; + } + + return [ + 'templates' => $templates, + 'path' => $configPath, + ]; + } + + /** + * Get the path to the appropriate PHP binary. + * + * @return string + */ + protected function phpBinary(): string + { + $phpBinary = function_exists('Illuminate\Support\php_binary') + ? php_binary() + : (new PhpExecutableFinder)->find(false); + + return $phpBinary !== false + ? ProcessUtils::escapeArgument($phpBinary) + : 'php'; + } + + /** + * Run the given commands. + * + * @param array $commands + * @param InputInterface $input + * @param OutputInterface $output + * @param string|null $workingPath + * @param array $env + * @return Process + */ + protected function runCommands(array $commands, InputInterface $input, OutputInterface $output, ?string $workingPath = null, array $env = []): Process + { + if (!$output->isDecorated()) { + $commands = array_map(function ($value) { + if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) { + return $value; + } + + return $value.' --no-ansi'; + }, $commands); + } + + if ($input->getOption('quiet')) { + $commands = array_map(function ($value) { + if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) { + return $value; + } + + return $value.' --quiet'; + }, $commands); + } + + $process = Process::fromShellCommandline(implode(' && ', $commands), $workingPath, $env, null, null); + + if ('\\' !== DIRECTORY_SEPARATOR AND file_exists('/dev/tty') AND is_readable('/dev/tty')) { + try { + $process->setTty(true); + } catch (RuntimeException $e) { + $output->writeln(' WARN '.$e->getMessage().PHP_EOL); + } + } + + $process->run(function ($type, $line) use ($output) { + $output->write(' '.$line); + }); + + return $process; + } + + /** + * Get the installation directory. + * + * @param string $name + * @return string + */ + protected function getInstallationDirectory(string $name): string + { + return $name !== '.' ? getcwd().'/'.$name : '.'; + } + + /** + * Verify that the application does not already exist. + * + * @param string $directory + * @return void + */ + protected function verifyApplicationDoesntExist(string $directory): void + { + if ((is_dir($directory) || is_file($directory)) AND $directory != getcwd()) { + throw new RuntimeException('Application already exists!'); + } + } +} \ No newline at end of file diff --git a/src/Concerns/ConfiguresPrompts.php b/src/Concerns/ConfiguresPrompts.php index f71e12b..b53fafc 100644 --- a/src/Concerns/ConfiguresPrompts.php +++ b/src/Concerns/ConfiguresPrompts.php @@ -2,6 +2,7 @@ namespace HichemTabTech\LaravelFS\Console\Concerns; +use Closure; use Laravel\Prompts\ConfirmPrompt; use Laravel\Prompts\MultiSelectPrompt; use Laravel\Prompts\PasswordPrompt; @@ -19,11 +20,11 @@ trait ConfiguresPrompts /** * Configure the prompt fallbacks. * - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param InputInterface $input + * @param OutputInterface $output * @return void */ - protected function configurePrompts(InputInterface $input, OutputInterface $output) + protected function configurePrompts(InputInterface $input, OutputInterface $output): void { Prompt::fallbackWhen(! $input->isInteractive() || PHP_OS_FAMILY === 'Windows'); @@ -99,18 +100,18 @@ function () use ($prompt, $input, $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 + * @param Closure $prompt + * @param bool|string $required + * @param Closure|null $validate + * @param OutputInterface $output * @return mixed */ - protected function promptUntilValid($prompt, $required, $validate, $output) + protected function promptUntilValid(Closure $prompt, bool|string $required, ?Closure $validate, OutputInterface $output): mixed { while (true) { $result = $prompt(); - if ($required && ($result === '' || $result === [] || $result === false)) { + if ($required AND ($result === '' || $result === [] || $result === false)) { $output->writeln(''.(is_string($required) ? $required : 'Required.').''); continue; @@ -119,8 +120,8 @@ protected function promptUntilValid($prompt, $required, $validate, $output) if ($validate) { $error = $validate($result); - if (is_string($error) && strlen($error) > 0) { - $output->writeln("{$error}"); + if (is_string($error) AND strlen($error) > 0) { + $output->writeln("$error"); continue; } diff --git a/src/Concerns/InteractsWithHerdOrValet.php b/src/Concerns/InteractsWithHerdOrValet.php index 4d9c80b..4a84ba9 100644 --- a/src/Concerns/InteractsWithHerdOrValet.php +++ b/src/Concerns/InteractsWithHerdOrValet.php @@ -19,7 +19,7 @@ public function isParkedOnHerdOrValet(string $directory): bool $decodedOutput = json_decode($output); - return is_array($decodedOutput) && in_array(dirname($directory), $decodedOutput); + return is_array($decodedOutput) AND in_array(dirname($directory), $decodedOutput); } /** diff --git a/src/NewCommand.php b/src/NewCommand.php index d73a5ad..9257fa1 100644 --- a/src/NewCommand.php +++ b/src/NewCommand.php @@ -4,8 +4,6 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Composer; -use Illuminate\Support\ProcessUtils; -use Illuminate\Support\Str; use InvalidArgumentException; use JsonException; use RuntimeException; @@ -14,11 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; -use function Illuminate\Support\php_binary; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\error; use function Laravel\Prompts\multiselect; use function Laravel\Prompts\select; use function Laravel\Prompts\text; @@ -27,6 +24,7 @@ class NewCommand extends Command { use Concerns\ConfiguresPrompts; use Concerns\InteractsWithHerdOrValet; + use Concerns\CommandsUtils; /** * The Composer instance. @@ -35,6 +33,19 @@ class NewCommand extends Command */ protected Composer $composer; + /** + * Determine if the command is creating a template. + * + * @var bool + */ + protected bool $isCreatingTemplate; + + public function __construct(bool $isCreatingTemplate = false) + { + $this->isCreatingTemplate = $isCreatingTemplate; + parent::__construct(); + } + /** * Configure the command options. * @@ -44,9 +55,11 @@ protected function configure(): void { $this ->setName('new') - ->setDescription('Create a new Laravel application') - ->addArgument('name', InputArgument::REQUIRED) - ->addOption('dev', null, InputOption::VALUE_NONE, 'Install the latest "development" release') + ->setDescription('Create a new Laravel application'); + if (!$this->isCreatingTemplate()) { + $this->addArgument('name', InputArgument::REQUIRED); + } + $this->addOption('dev', null, InputOption::VALUE_NONE, 'Install the latest "development" release') ->addOption('git', null, InputOption::VALUE_NONE, 'Initialize a Git repository') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'The branch that should be created for a new repository', $this->defaultBranch()) ->addOption('github', null, InputOption::VALUE_OPTIONAL, 'Create a new repository on GitHub', false) @@ -78,6 +91,11 @@ protected function configure(): void ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces install even if the directory already exists'); } + protected function isCreatingTemplate(): bool + { + return $this->isCreatingTemplate; + } + /** * Interact with the user before validating the input. * @@ -100,7 +118,55 @@ protected function interact(InputInterface $input, OutputInterface $output): voi $this->ensureExtensionsAreAvailable(); - if (! $input->getArgument('name')) { + if ($this->isCreatingTemplate()) { + if (!$input->getArgument('template-name')) { + $input->setArgument('template-name', text( + label: 'What is the name this template', + placeholder: 'E.g. template1, or-any-name-u-want', + required: 'The template name is required.', + validate: function ($value) use ($input) { + if (preg_match('/[^\pL\pN\-_.]/', $value) !== 0) { + return 'The template name may only contain letters, numbers, dashes, underscores, and periods.'; + } + + $templatesData = $this->getSavedTemplates(true); + $templates = $templatesData['templates']; + if (!empty($templates)) { + if (isset($templates[$value])) { + if (confirm( + label: 'A template with this name already exists. Would you like to overwrite it?', + default: false, + )) { + return null; + } else { + return 'A template with this name already exists. Please choose a different name.'; + } + } + } + + return null; + }, + hint: 'This is the name of the template that will be used as a key to re-create the template later.', + )); + } + + if (!$input->getArgument('template-description')) { + $input->setArgument('template-description', text( + label: 'Provide a description for this template (Not required)', + placeholder: 'E.g. A breeze starter with ssr,dark but no typescript', + validate: function ($value) use ($input) { + if (preg_match('/[^\pL\pN\-\s_.]/', $value) !== 0) { + return 'The description may only contain letters, numbers, dashes, underscores, and periods.'; + } + + return null; + }, + hint: 'This is a description for the template that will be used to describe the template.', + )); + } + } + + if (!$this->isCreatingTemplate() AND !$input->getArgument('name')) { $input->setArgument('name', text( label: 'What is the name of your project?', placeholder: 'E.g. example-app', @@ -123,13 +189,13 @@ protected function interact(InputInterface $input, OutputInterface $output): voi )); } - if ($input->getOption('force') !== true) { + if ($input->getOption('force') !== true AND !$this->isCreatingTemplate()) { $this->verifyApplicationDoesntExist( $this->getInstallationDirectory($input->getArgument('name')) ); } - if (! $input->getOption('react') && ! $input->getOption('vue') && ! $input->getOption('livewire') && ! $input->getOption('breeze') && ! $input->getOption('jet') && ! $input->getOption('custom-starter')) { + if (!$input->getOption('react') AND !$input->getOption('vue') AND !$input->getOption('livewire') AND !$input->getOption('breeze') AND !$input->getOption('jet') AND !$input->getOption('custom-starter')) { match (select( label: 'Which starter kit would you like to install?', options: [ @@ -189,15 +255,15 @@ protected function interact(InputInterface $input, OutputInterface $output): voi }; } - if ($input->getOption('livewire') && ! $input->getOption('workos')) { - $input->setOption('livewire-class-components', ! confirm( + if ($input->getOption('livewire') AND !$input->getOption('workos')) { + $input->setOption('livewire-class-components', !confirm( label: 'Would you like to use Laravel Volt?', )); } if ($this->usingStarterKit($input)) { - if (! $input->getOption('phpunit') && - ! $input->getOption('pest')) { + if (!$input->getOption('phpunit') && + !$input->getOption('pest')) { $input->setOption('pest', select( label: 'Which testing framework do you prefer?', options: ['Pest', 'PHPUnit'], @@ -205,7 +271,7 @@ protected function interact(InputInterface $input, OutputInterface $output): voi ) === 'Pest'); } } elseif ($this->usingLegacyStarterKit($input)) { - if (! $input->getOption('phpunit') && ! $input->getOption('pest')) { + if (!$input->getOption('phpunit') AND !$input->getOption('pest')) { $input->setOption('pest', select( label: 'Which testing framework do you prefer?', options: ['Pest', 'PHPUnit'], @@ -260,6 +326,39 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->validateDatabaseOption($input); $this->validateStackOption($input); + if ($this->isCreatingTemplate()) { + // collect the options and create a single command + $templateName = $input->getArgument('template-name'); + $templateDescription = $input->getArgument('template-description'); + $templateCommand = $this->createTemplateCommand($input); + + if (preg_match('/[^\pL\pN\-_.]/', $input->getArgument('template-name')) !== 0) { + error('The template name may only contain letters, numbers, dashes, underscores, and periods.'); + return Command::INVALID; + } + $templatesData = $this->getSavedTemplates(true); + $templates = $templatesData['templates']; + if (!empty($templates)) { + if (isset($templates[$input->getArgument('template-name')])) { + if (!confirm( + label: 'A template with this name already exists. Would you like to overwrite it?', + default: false, + )) { + error('A template with this name already exists. Please choose a different name.'); + return Command::INVALID; + } + } + } + + // Save the template globally + $this->saveTemplateCommand($templateName, $templateDescription, $templateCommand); + + $output->writeln(" INFO Template [$templateName] created successfully."); + $output->writeln(" ➜ You can now use this template by running laravelfs use $templateName"); + + return Command::SUCCESS; + } + $name = rtrim($input->getArgument('name'), '/\\'); $directory = $this->getInstallationDirectory($name); @@ -268,11 +367,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $version = $this->getVersion($input); - if (! $input->getOption('force')) { + if (!$input->getOption('force')) { $this->verifyApplicationDoesntExist($directory); } - if ($input->getOption('force') && $directory === '.') { + if ($input->getOption('force') AND $directory === '.') { throw new RuntimeException('Cannot use --force option when using current directory for installation!'); } @@ -311,15 +410,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $phpBinary." \"$directory/artisan\" key:generate --ansi", ]; - if ($directory != '.' && $input->getOption('force')) { - if (PHP_OS_FAMILY == 'Windows') { + if ($directory != '.' AND $input->getOption('force')) { + if (windows_os()) { array_unshift($commands, "(if exist \"$directory\" rd /s /q \"$directory\")"); } else { array_unshift($commands, "rm -rf \"$directory\""); } } - if (PHP_OS_FAMILY != 'Windows') { + if (!windows_os()) { $commands[] = "chmod 755 \"$directory/artisan\""; } @@ -343,7 +442,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $commands = [ trim(sprintf( $this->phpBinary().' artisan migrate %s', - ! $input->isInteractive() ? '--no-interaction' : '', + !$input->isInteractive() ? '--no-interaction' : '', )), ]; @@ -376,7 +475,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $runNpm = $input->getOption('npm'); - if (! $input->getOption('npm') && $input->isInteractive()) { + if (!$input->getOption('npm') AND $input->isInteractive()) { $runNpm = confirm( label: 'Would you like to run npm install and npm run build?' ); @@ -389,8 +488,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(" INFO Application ready in [$name]. You can start your local development using:".PHP_EOL); $output->writeln('➜ cd '.$name.''); - if (! $runNpm) { - $output->writeln('➜ npm install && npm run build'); + if (!$runNpm) { + $output->writeln('➜ npm install AND npm run build'); } if ($this->isParkedOnHerdOrValet($directory)) { @@ -421,7 +520,7 @@ protected function defaultBranch(): string $output = trim($process->getOutput()); - return $process->isSuccessful() && $output ? $output : 'main'; + return $process->isSuccessful() AND $output || 'main'; } /** @@ -450,7 +549,7 @@ protected function configureDefaultDatabaseConnection(string $directory, string $environment = file_get_contents($directory.'/.env'); // If database options aren't commented, comment them for SQLite... - if (! str_contains($environment, '# DB_HOST=127.0.0.1')) { + if (!str_contains($environment, '# DB_HOST=127.0.0.1')) { $this->commentDatabaseConfigurationForSqlite($directory); return; @@ -628,7 +727,7 @@ protected function promptForDatabaseOptions(InputInterface $input): array $input->setOption('database', 'sqlite'); } - if (! $input->getOption('database') && $input->isInteractive()) { + if (!$input->getOption('database') AND $input->isInteractive()) { $input->setOption('database', select( label: 'Which database will your application use?', options: $databaseOptions, @@ -673,7 +772,7 @@ protected function databaseOptions(): array */ protected function validateDatabaseOption(InputInterface $input): void { - if ($input->getOption('database') && ! in_array($input->getOption('database'), $drivers = ['mysql', 'mariadb', 'pgsql', 'sqlite', 'sqlsrv'])) { + if ($input->getOption('database') AND !in_array($input->getOption('database'), $drivers = ['mysql', 'mariadb', 'pgsql', 'sqlite', 'sqlsrv'])) { throw new InvalidArgumentException("Invalid database driver [{$input->getOption('database')}]. Valid options are: ".implode(', ', $drivers).'.'); } } @@ -686,7 +785,7 @@ protected function validateDatabaseOption(InputInterface $input): void protected function validateStackOption(InputInterface $input): void { if ($input->getOption('breeze')) { - if (! in_array($input->getOption('stack'), $stacks = ['blade', 'livewire', 'livewire-functional', 'react', 'vue', 'api'])) { + if (!in_array($input->getOption('stack'), $stacks = ['blade', 'livewire', 'livewire-functional', 'react', 'vue', 'api'])) { throw new InvalidArgumentException("Invalid Breeze stack [{$input->getOption('stack')}]. Valid options are: ".implode(', ', $stacks).'.'); } @@ -694,7 +793,7 @@ protected function validateStackOption(InputInterface $input): void } if ($input->getOption('jet')) { - if (! in_array($input->getOption('stack'), $stacks = ['inertia', 'livewire'])) { + if (!in_array($input->getOption('stack'), $stacks = ['inertia', 'livewire'])) { throw new InvalidArgumentException("Invalid Jetstream stack [{$input->getOption('stack')}]. Valid options are: ".implode(', ', $stacks).'.'); } } @@ -708,7 +807,7 @@ protected function validateStackOption(InputInterface $input): void */ protected function promptForBreezeOptions(InputInterface $input): void { - if (! $input->getOption('stack')) { + if (!$input->getOption('stack')) { $input->setOption('stack', select( label: 'Which Breeze stack would you like to install?', options: [ @@ -723,7 +822,7 @@ protected function promptForBreezeOptions(InputInterface $input): void )); } - if (in_array($input->getOption('stack'), ['react', 'vue']) && (! $input->getOption('dark') || ! $input->getOption('ssr'))) { + if (in_array($input->getOption('stack'), ['react', 'vue']) AND (!$input->getOption('dark') || !$input->getOption('ssr'))) { collect(multiselect( label: 'Would you like any optional features?', options: [ @@ -739,7 +838,7 @@ protected function promptForBreezeOptions(InputInterface $input): void $input->getOption('eslint') ? 'eslint' : null, ]), ))->each(fn ($option) => $input->setOption($option, true)); - } elseif (in_array($input->getOption('stack'), ['blade', 'livewire', 'livewire-functional']) && ! $input->getOption('dark')) { + } elseif (in_array($input->getOption('stack'), ['blade', 'livewire', 'livewire-functional']) AND !$input->getOption('dark')) { $input->setOption('dark', confirm( label: 'Would you like dark mode support?', default: false, @@ -755,7 +854,7 @@ protected function promptForBreezeOptions(InputInterface $input): void */ protected function promptForJetstreamOptions(InputInterface $input): void { - if (! $input->getOption('stack')) { + if (!$input->getOption('stack')) { $input->setOption('stack', select( label: 'Which Jetstream stack would you like to install?', options: [ @@ -782,7 +881,7 @@ protected function promptForJetstreamOptions(InputInterface $input): void $input->getOption('dark') ? 'dark' : null, $input->getOption('teams') ? 'teams' : null, $input->getOption('verification') ? 'verification' : null, - $input->getOption('stack') === 'inertia' && $input->getOption('ssr') ? 'ssr' : null, + $input->getOption('stack') === 'inertia' AND $input->getOption('ssr') ? 'ssr' : null, ]), ))->each(fn ($option) => $input->setOption($option, true)); } @@ -834,7 +933,7 @@ public function installPest(string $directory, InputInterface $input, OutputInte ); } - if (($input->getOption('react') || $input->getOption('vue') || $input->getOption('livewire')) && $input->getOption('phpunit')) { + if (($input->getOption('react') || $input->getOption('vue') || $input->getOption('livewire')) AND $input->getOption('phpunit')) { $this->deleteFile($directory.'/tests/Pest.php'); } @@ -874,7 +973,7 @@ public function createRepository(string $directory, InputInterface $input, Outpu */ protected function commitChanges(string $message, string $directory, InputInterface $input, OutputInterface $output): void { - if (! $input->getOption('git') && $input->getOption('github') === false) { + if (!$input->getOption('git') AND $input->getOption('github') === false) { return; } @@ -900,7 +999,7 @@ protected function pushToGitHub(string $name, string $directory, InputInterface $process = new Process(['gh', 'auth', 'status']); $process->run(); - if (! $process->isSuccessful()) { + if (!$process->isSuccessful()) { $output->writeln(' WARN Make sure the "gh" CLI tool is installed and that you\'re authenticated to GitHub. Skipping...'.PHP_EOL); return; @@ -936,19 +1035,6 @@ protected function configureComposerDevScript(): void }); } - /** - * Verify that the application does not already exist. - * - * @param string $directory - * @return void - */ - protected function verifyApplicationDoesntExist(string $directory): void - { - if ((is_dir($directory) || is_file($directory)) && $directory != getcwd()) { - throw new RuntimeException('Application already exists!'); - } - } - /** * Generate a valid APP_URL for the given application name. * @@ -1005,17 +1091,6 @@ protected function canResolveHostname(string $hostname): bool return gethostbyname($hostname.'.') !== $hostname.'.'; } - /** - * Get the installation directory. - * - * @param string $name - * @return string - */ - protected function getInstallationDirectory(string $name): string - { - return $name !== '.' ? getcwd().'/'.$name : '.'; - } - /** * Get the version that should be downloaded. * @@ -1041,71 +1116,6 @@ protected function findComposer(): string return implode(' ', $this->composer->findComposer()); } - /** - * Get the path to the appropriate PHP binary. - * - * @return string - */ - protected function phpBinary(): string - { - $phpBinary = function_exists('Illuminate\Support\php_binary') - ? php_binary() - : (new PhpExecutableFinder)->find(false); - - return $phpBinary !== false - ? ProcessUtils::escapeArgument($phpBinary) - : 'php'; - } - - /** - * Run the given commands. - * - * @param array $commands - * @param InputInterface $input - * @param OutputInterface $output - * @param string|null $workingPath - * @param array $env - * @return Process - */ - protected function runCommands(array $commands, InputInterface $input, OutputInterface $output, ?string $workingPath = null, array $env = []): Process - { - if (! $output->isDecorated()) { - $commands = array_map(function ($value) { - if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) { - return $value; - } - - return $value.' --no-ansi'; - }, $commands); - } - - if ($input->getOption('quiet')) { - $commands = array_map(function ($value) { - if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) { - return $value; - } - - return $value.' --quiet'; - }, $commands); - } - - $process = Process::fromShellCommandline(implode(' && ', $commands), $workingPath, $env, null, null); - - if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { - try { - $process->setTty(true); - } catch (RuntimeException $e) { - $output->writeln(' WARN '.$e->getMessage().PHP_EOL); - } - } - - $process->run(function ($type, $line) use ($output) { - $output->write(' '.$line); - }); - - return $process; - } - /** * Replace the given file. * @@ -1165,4 +1175,65 @@ protected function deleteFile(string $file): void { unlink($file); } + + private function createTemplateCommand(InputInterface $input): string + { + $commandParts = ['laravelfs', 'new', '']; + $options = $input->getOptions(); + + foreach ($options as $key => $value) { + // Skip options that are false, null, or empty string. + if ($value === false || $value === null || $value === '') { + continue; + } + + // If it's a boolean true, just add the flag. + if ($value === true) { + $commandParts[] = '--' . $key; + } else { + // Otherwise, add the option with its value. + $commandParts[] = sprintf('--%s=%s', $key, escapeshellarg($value)); + } + } + + // Add the --no-interaction flag. + $commandParts[] = '--no-interaction'; + + return implode(' ', $commandParts); + } + + private function saveTemplateCommand(mixed $templateName, mixed $templateDescription, string $templateCommand): void + { + // Get the global config path for storing templates + $configPath = $this->getGlobalTemplatesPath(); + + // Ensure the directory exists + $configDir = dirname($configPath); + if (!is_dir($configDir)) { + mkdir($configDir, 0755, true); + } + + // Load existing templates if any + $templatesConfig = [ + "templates" => [] + ]; + if (file_exists($configPath)) { + $templatesConfig = json_decode(file_get_contents($configPath), true) ?? []; + if (empty($templatesConfig)) { + $templatesConfig = [ + "templates" => [] + ]; + } + if (!isset($templatesConfig['templates'])) { + $templatesConfig['templates'] = []; + } + } + + // Save the new template + $templatesConfig["templates"][$templateName] = [ + 'description' => $templateDescription??"", + 'command' => $templateCommand, + ]; + file_put_contents($configPath, json_encode($templatesConfig, JSON_PRETTY_PRINT)); + } } diff --git a/src/NewTemplateCommand.php b/src/NewTemplateCommand.php new file mode 100644 index 0000000..b600510 --- /dev/null +++ b/src/NewTemplateCommand.php @@ -0,0 +1,31 @@ +setName('template:new') + ->setDescription('Create and save a custom starter template') + ->setHelp( + 'This command mimics the "new" command prompts to collect all configuration options for a Laravel project starter kit. ' . + 'Instead of creating a new project, it assembles your chosen options into a command that you can use later, ' . + 'letting you quickly re-create your custom starter template later.' + ) + ->addArgument('template-name', InputArgument::REQUIRED) + ->addArgument( + 'template-description', + InputArgument::OPTIONAL, + 'This is a description for the template that will be used to describe the template.' + ); + } +} diff --git a/src/ShowTemplatesCommand.php b/src/ShowTemplatesCommand.php new file mode 100644 index 0000000..602ca56 --- /dev/null +++ b/src/ShowTemplatesCommand.php @@ -0,0 +1,80 @@ +setName('template:show') + ->setDescription('Show all saved templates') + ->setHelp('This command shows all saved templates that you can use to create a new Laravel project.') + ->addArgument('template', InputOption::VALUE_OPTIONAL, 'Show a specific template') + ->setAliases(['templates']); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->showTemplates($input); + return Command::SUCCESS; + } + + + private function showTemplates(InputInterface $input): void + { + $templatesData = $this->getSavedTemplates(); + $templates = $templatesData['templates']; + if (empty($templates)) { + return; + } + + $path = $templatesData['path']; + intro("Templates are saved in $path"); + + if ($template = $input->getArgument('template')) { + if (!isset($templates[$template])) { + error("Template '$template' not found."); + return; + } + + // Display the template + table( + ['Template Name', 'Description', 'Command'], + [$this->formatTemplates($template, $templates[$template])] + ); + } + else { + // Format and display templates using Laravel Prompts table + table( + ['Template Name', 'Description', 'Command'], + array_map(fn($name, $template) => $this->formatTemplates($name, $template), array_keys($templates), $templates) + ); + } + + info('Use a template by calling `laravelfs use `<'.'/'.'>'); + } + + private function formatTemplates($name, $data): array + { + $maxLength = 50; + $description = $data['description'] ?? 'No description'; + $command = $data['command'] ?? ''; + + return [ + $name, + strlen($description) > $maxLength ? substr($description, 0, $maxLength) . '...' : $description, + strlen($command) > $maxLength ? substr($command, 0, $maxLength) . '...' : $command, + ]; + } +} \ No newline at end of file diff --git a/src/UseTemplateCommand.php b/src/UseTemplateCommand.php new file mode 100644 index 0000000..0e598c9 --- /dev/null +++ b/src/UseTemplateCommand.php @@ -0,0 +1,103 @@ +setName('use') + ->setDescription('Use a saved template to create a new Laravel project') + ->setHelp('This command uses a saved template to create a new Laravel project.') + ->addArgument('template-name', InputArgument::REQUIRED, 'The name of the template to use') + ->addArgument('project-name', InputArgument::REQUIRED) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces install even if the directory already exists'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + parent::interact($input, $output); + + $this->configurePrompts($input, $output); + + $output->write(PHP_EOL.' _ _ + | | | | + | | __ _ _ __ __ ___ _____| | + | | / _` | __/ _` \ \ / / _ \ | + | |___| (_| | | | (_| |\ V / __/ | + |______\__,_|_| \__,_| \_/ \___|_|'.PHP_EOL.PHP_EOL); + + if (!$input->getArgument('template-name')) { + $templatesData = $this->getSavedTemplates(true); + $templates = $templatesData['templates']; + $input->setArgument('template-name', text( + label: 'What is the name this template', + placeholder: count($templates) == 0 ? 'E.g. template1, or-any-name-u-want' : ('E.g. '.implode(', ', array_slice(array_keys($templates), 0, 3)).(count($templates) > 3 ? ', ...' : '')), + required: 'The template name is required.', + hint: 'This name is the key of the template you are searching for.', + )); + } + + if (!$input->getArgument('project-name')) { + $input->setArgument('project-name', text( + label: 'What is the name of your project?', + placeholder: 'E.g. example-app', + required: 'The project name is required.', + validate: function ($value) use ($input) { + if (preg_match('/[^\pL\pN\-_.]/', $value) !== 0) { + return 'The name may only contain letters, numbers, dashes, underscores, and periods.'; + } + + if ($input->getOption('force') !== true) { + try { + $this->verifyApplicationDoesntExist($this->getInstallationDirectory($value)); + } catch (RuntimeException) { + return 'Application already exists.'; + } + } + + return null; + }, + )); + } + } + + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->useTemplate($input, $output); + return Command::SUCCESS; + } + + private function useTemplate(InputInterface $input, OutputInterface $output): void + { + $templatesData = $this->getSavedTemplates(); + $templates = $templatesData['templates']; + if (empty($templates)) { + return; + } + + $templateName = $input->getArgument('template-name'); + + if (!isset($templates[$templateName])) { + error("Template '$templateName' not found."); + return; + } + + $template = $templates[$templateName]; + $template['command'] = str_replace('', $input->getArgument('project-name'), $template['command']); + + $this->runCommands([$template['command']], $input, $output); + } +} \ No newline at end of file diff --git a/tests/Unit/DatabaseOptionsTest.php b/tests/Unit/DatabaseOptionsTest.php index 190a69a..1819200 100644 --- a/tests/Unit/DatabaseOptionsTest.php +++ b/tests/Unit/DatabaseOptionsTest.php @@ -30,7 +30,7 @@ public function simulatePromptForDatabaseOptions(ArrayInput $input, ?string $sim if ($this->usingStarterKit($input)) { $input->setOption('database', 'sqlite'); $migrate = false; - } elseif (! $input->getOption('database') && $input->isInteractive()) { + } elseif (! $input->getOption('database') AND $input->isInteractive()) { // Instead of calling the actual prompt, simulate the responses. $input->setOption('database', $simulatedDatabase ?? $defaultDatabase);