From 1d537788dec72b1819e5cdc0d37e2c076e1a0002 Mon Sep 17 00:00:00 2001 From: HichemTech Date: Wed, 5 Mar 2025 12:09:51 +0100 Subject: [PATCH 1/4] Add template management commands to LaravelFS Introduced commands for creating, using, and displaying saved templates. Enhanced flexibility for project scaffolding with customizable starter templates. Updated the tool version to 2.0.0 to reflect these significant new features. --- bin/laravelfs | 5 +- src/Concerns/CommandsUtils.php | 162 +++++++++++ src/Concerns/ConfiguresPrompts.php | 23 +- src/Concerns/InteractsWithHerdOrValet.php | 2 +- src/NewCommand.php | 325 +++++++++++++--------- src/NewTemplateCommand.php | 31 +++ src/ShowTemplatesCommand.php | 80 ++++++ src/UseTemplateCommand.php | 103 +++++++ tests/Unit/DatabaseOptionsTest.php | 2 +- 9 files changed, 592 insertions(+), 141 deletions(-) create mode 100644 src/Concerns/CommandsUtils.php create mode 100644 src/NewTemplateCommand.php create mode 100644 src/ShowTemplatesCommand.php create mode 100644 src/UseTemplateCommand.php 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); From 3992acc76ca6d2128a01800463b3b3f2d8bf029e Mon Sep 17 00:00:00 2001 From: HichemTech Date: Wed, 5 Mar 2025 12:18:50 +0100 Subject: [PATCH 2/4] Add GitHub Action to comment on new pull requests This workflow triggers on newly opened pull requests and posts a thank-you comment to the author. It aims to enhance contributor engagement by acknowledging their submissions promptly. --- .github/workflows/pull_request.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/pull_request.yml diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..3799b44 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,21 @@ +name: Thank You Comment on New Pull Request + +on: + pull_request: + types: [opened] + +jobs: + thank-you-pr: + runs-on: ubuntu-latest + + steps: + - name: Post thank you comment for the pull request author + uses: actions/github-script@v6 + with: + script: | + github.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `Thank you @${context.payload.pull_request.user.login} for submitting this pull request! We greatly appreciate your contribution. Our team will review your PR soon. 🚀` + }); \ No newline at end of file From 46bc724d0aae0541d5cbe75084ad7a804061ae40 Mon Sep 17 00:00:00 2001 From: HichemTech Date: Wed, 5 Mar 2025 12:24:05 +0100 Subject: [PATCH 3/4] Refactor PR workflow to add assignee and improve comments Replaced the old "Thank You Comment" workflow with a new "PR Welcome & Assign" workflow. The updated workflow automatically assigns the PR to a maintainer and posts a welcome comment only if the author is not the maintainer. This improves efficiency and provides clearer communication. --- .github/workflows/pr-welcome.yml | 37 ++++++++++++++++++++++++++++++ .github/workflows/pull_request.yml | 21 ----------------- 2 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/pr-welcome.yml delete mode 100644 .github/workflows/pull_request.yml diff --git a/.github/workflows/pr-welcome.yml b/.github/workflows/pr-welcome.yml new file mode 100644 index 0000000..3d00452 --- /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 + + steps: + - name: Get PR Author + id: pr_author + run: echo "AUTHOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV + + - name: Assign PR to Maintainer + uses: actions/github-script@v6 + with: + script: | + github.issues.addAssignees({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + assignees: ["HichemTab-tech"] + }); + + - name: Comment on PR (if not the maintainer) + #if: env.AUTHOR != 'HichemTab-tech' + uses: actions/github-script@v6 + with: + script: | + github.issues.createComment({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Thank you @${{ github.event.pull_request.user.login }} 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/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml deleted file mode 100644 index 3799b44..0000000 --- a/.github/workflows/pull_request.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Thank You Comment on New Pull Request - -on: - pull_request: - types: [opened] - -jobs: - thank-you-pr: - runs-on: ubuntu-latest - - steps: - - name: Post thank you comment for the pull request author - uses: actions/github-script@v6 - with: - script: | - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `Thank you @${context.payload.pull_request.user.login} for submitting this pull request! We greatly appreciate your contribution. Our team will review your PR soon. 🚀` - }); \ No newline at end of file From 3bb8ced4174a6167f314396d3df914b750c7838e Mon Sep 17 00:00:00 2001 From: HichemTech Date: Wed, 5 Mar 2025 12:29:28 +0100 Subject: [PATCH 4/4] Update PR workflow to use auto-assign action Replaced custom GitHub script with the `pozil/auto-assign-issue` action for assigning PRs to maintainers, improving clarity and maintainability. Adjusted permissions and streamlined the comment step to use the author environment variable. --- .github/workflows/pr-welcome.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-welcome.yml b/.github/workflows/pr-welcome.yml index 3d00452..c79d0ba 100644 --- a/.github/workflows/pr-welcome.yml +++ b/.github/workflows/pr-welcome.yml @@ -7,22 +7,21 @@ on: 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: Assign PR to Maintainer - uses: actions/github-script@v6 + - name: 'Auto-assign issue' + uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e with: - script: | - github.issues.addAssignees({ - issue_number: context.payload.pull_request.number, - owner: context.repo.owner, - repo: context.repo.repo, - assignees: ["HichemTab-tech"] - }); + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: HichemTab-tech + numOfAssignee: 1 - name: Comment on PR (if not the maintainer) #if: env.AUTHOR != 'HichemTab-tech' @@ -33,5 +32,5 @@ jobs: issue_number: context.payload.pull_request.number, owner: context.repo.owner, repo: context.repo.repo, - body: "Thank you @${{ github.event.pull_request.user.login }} for your contribution! 🎉\n\nYour PR has been received and is awaiting review. Please be patient while we check it. 🚀" + 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