From 97b52bd0d1e4bb507e172b276f4b403b8b8538c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 31 Oct 2025 17:34:10 +0200 Subject: [PATCH 1/3] refactor: site and server CRUD improvements --- app/Console/Server/ServerDeleteCommand.php | 1 + app/Console/Site/SiteAddCommand.php | 64 ++++------- app/Console/Site/SiteDeleteCommand.php | 36 +++++-- app/Console/Site/SiteListCommand.php | 27 ++--- app/Services/IOService.php | 43 ++++++++ app/Traits/ServerHelpersTrait.php | 28 ++--- app/Traits/SiteHelpersTrait.php | 119 ++++++++------------- app/Traits/SiteValidationTrait.php | 10 +- 8 files changed, 176 insertions(+), 152 deletions(-) diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index 7bdf8008..66062866 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -62,6 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->hr(); $this->displayServerDeets($server, $serverSites); + $this->io->writeln(''); if (count($serverSites) > 0) { $this->io->error("Cannot delete server '{$server->name}' because it has one or more sites."); diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 51e1e138..28b19dab 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -6,6 +6,7 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; +use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; use Bigpixelrocket\DeployerPHP\Traits\SiteValidationTrait; use Symfony\Component\Console\Attribute\AsCommand; @@ -22,6 +23,7 @@ #[AsCommand(name: 'site:add', description: 'Add a new site to the inventory')] class SiteAddCommand extends BaseCommand { + use ServerHelpersTrait; use SiteHelpersTrait; use SiteValidationTrait; @@ -35,10 +37,10 @@ protected function configure(): void $this ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Domain name') - ->addOption('type', null, InputOption::VALUE_REQUIRED, 'Site type: git or local') + ->addOption('source', null, InputOption::VALUE_REQUIRED, 'Site source: git or local') ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'Git repository URL (for git sites)') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'Git branch name (for git sites)') - ->addOption('servers', null, InputOption::VALUE_REQUIRED, 'Comma-separated server names'); + ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); } // @@ -50,23 +52,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); - $this->io->h1('Add New Site'); // - // Check if there are any servers - - if (count($this->servers->all()) === 0) { - $this->io->warning('No servers available'); - $this->io->writeln([ - '', - 'You must add at least one server before adding a site.', - 'Run server:provision to provision your first server,', - 'or run server:add to add an existing server.', - '', - ]); + // Select server - return Command::FAILURE; + $server = $this->selectServer(); + + if (is_int($server)) { + $this->io->warning('You must add at least one server before adding a site.'); + $this->io->writeln(''); + return $server; } // @@ -89,11 +85,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // - // Select site type + // Select site source - /** @var string $siteType */ - $siteType = $this->io->getOptionOrPrompt( - 'type', + /** @var string $siteSource */ + $siteSource = $this->io->getOptionOrPrompt( + 'source', fn (): string => (string) $this->io->promptSelect( label: 'Deploy from:', options: ['git' => 'Git Repository', 'local' => 'Local files'], @@ -101,7 +97,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) ); - $isLocal = $siteType === 'local'; + $isLocal = $siteSource === 'local'; // // Gather git-specific details @@ -149,28 +145,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - // - // Select servers - - try { - $selectedServers = $this->selectServers(); - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); - - return Command::FAILURE; - } - - // - // Validate selections - - try { - $this->validateServers($selectedServers); - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); - - return Command::FAILURE; - } - // // Create DTO and display site info @@ -178,7 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int domain: $domain, repo: $repo, branch: $branch, - servers: $selectedServers + servers: [$server->name] ); $this->io->hr(); @@ -204,8 +178,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $hintOptions = [ 'domain' => $domain, - 'type' => $siteType, - 'servers' => implode(',', $selectedServers), + 'source' => $siteSource, + 'server' => $server->name, ]; if (!$isLocal) { diff --git a/app/Console/Site/SiteDeleteCommand.php b/app/Console/Site/SiteDeleteCommand.php index 269ea56c..a84ce9c4 100644 --- a/app/Console/Site/SiteDeleteCommand.php +++ b/app/Console/Site/SiteDeleteCommand.php @@ -30,6 +30,7 @@ protected function configure(): void $this ->addOption('site', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing site domain (use with caution)') ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); } @@ -42,32 +43,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); - $this->io->h1('Delete Site'); // // Select site - $selection = $this->selectSite(); + $site = $this->selectSite(); - if ($selection['site'] === null) { - return $selection['exit_code']; + if (!$site instanceof \Bigpixelrocket\DeployerPHP\DTOs\SiteDTO) { + return $site; } - $site = $selection['site']; + $this->io->hr(); + $this->displaySiteDeets($site); + $this->io->writeln(''); // - // Confirm deletion + // Confirm deletion with extra safety - $this->io->writeln(''); + /** @var bool $forceSkip */ + $forceSkip = $input->getOption('force') ?? false; + + if (!$forceSkip) { + $typedDomain = $this->io->promptText( + label: "Type the site domain '{$site->domain}' to confirm deletion:", + required: true + ); + + if ($typedDomain !== $site->domain) { + $this->io->error('Site domain does not match. Deletion cancelled.'); + $this->io->writeln(''); + + return Command::FAILURE; + } + } /** @var bool $confirmed */ $confirmed = $this->io->getOptionOrPrompt( 'yes', fn (): bool => $this->io->promptConfirm( - label: 'Are you sure you want to delete this site?', - default: true + label: 'Are you absolutely sure?', + default: false ) ); @@ -92,6 +109,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->showCommandHint('site:delete', [ 'site' => $site->domain, 'yes' => $confirmed, + 'force' => true, ]); return Command::SUCCESS; diff --git a/app/Console/Site/SiteListCommand.php b/app/Console/Site/SiteListCommand.php index 4edd7a7e..a3dc2e4c 100644 --- a/app/Console/Site/SiteListCommand.php +++ b/app/Console/Site/SiteListCommand.php @@ -14,7 +14,7 @@ /** * List all sites in the inventory. */ -#[AsCommand(name: 'site:list', description: 'List all sites in the inventory')] +#[AsCommand(name: 'site:list', description: 'List sites in the inventory')] class SiteListCommand extends BaseCommand { use SiteHelpersTrait; @@ -28,26 +28,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); + $this->io->h1('List Sites'); // // Get all sites - $allSites = $this->sites->all(); - if (count($allSites) === 0) { - $this->io->warning('No sites found in inventory'); - $this->io->writeln([ - '', - 'Use site:add to add a site', - '', - ]); + $allSites = $this->ensureSitesAvailable(); - return Command::SUCCESS; + if (is_int($allSites)) { + return $allSites; } - $this->io->h1('All Sites'); + // + // Display sites - foreach ($allSites as $site) { + foreach ($allSites as $count => $site) { $this->displaySiteDeets($site); + + if ($count < count($allSites) - 1) { + $this->io->writeln([ + ' ───', + '', + ]); + } } return Command::SUCCESS; diff --git a/app/Services/IOService.php b/app/Services/IOService.php index aa04e556..fb9cda03 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -522,6 +522,49 @@ public function hr(): void ]); } + /** + * Display key-value details with aligned formatting. + * + * Formats key-value pairs with proper alignment and gray styling for values. + * + * @param array> $details Key-value pairs to display + * + * @example + * $this->io->displayDeets([ + * 'Name' => 'production-web-01', + * 'Host' => '192.168.1.100', + * 'Port' => 22, + * ]); + * // Output: + * // Name: production-web-01 + * // Host: 192.168.1.100 + * // Port: 22 + */ + public function displayDeets(array $details): void + { + if (empty($details)) { + return; + } + + // Find longest key for alignment + $maxLength = max(array_map('strlen', array_keys($details))); + + $lines = []; + foreach ($details as $key => $value) { + $paddedKey = str_pad($key.':', $maxLength + 1); + if (is_array($value)) { + $lines[] = " {$paddedKey}"; + foreach ($value as $item) { + $lines[] = " • {$item}"; + } + } else { + $lines[] = " {$paddedKey} {$value}"; + } + } + + $this->writeln($lines); + } + /** * Display a command replay hint showing how to run non-interactively. * diff --git a/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php index 54914d07..c185b486 100644 --- a/app/Traits/ServerHelpersTrait.php +++ b/app/Traits/ServerHelpersTrait.php @@ -5,6 +5,7 @@ namespace Bigpixelrocket\DeployerPHP\Traits; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\IOService; use Symfony\Component\Console\Command\Command; @@ -56,6 +57,7 @@ protected function selectServer(string $optionName = 'server', string $promptLab // Get all servers $allServers = $this->ensureServersAvailable(); + if (is_int($allServers)) { return $allServers; } @@ -94,21 +96,21 @@ protected function selectServer(string $optionName = 'server', string $promptLab */ protected function displayServerDeets(ServerDTO $server, array $sites = []): void { - $this->io->writeln([ - " Name: {$server->name}", - " Host: {$server->host}", - " Port: {$server->port}", - " User: {$server->username}", - ' Key: '.($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)').'', - ]); - - if (count($sites) > 0) { - $this->io->writeln([' Sites:']); - foreach ($sites as $site) { - $this->io->writeln([" • {$site->domain}"]); - } + $deets = [ + 'Name' => $server->name, + 'Host' => $server->host, + 'Port' => $server->port, + 'User' => $server->username, + 'Key' => $server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)', + ]; + + if (count($sites) > 1) { + $deets['Sites'] = array_map(fn (SiteDTO $site) => $site->domain, $sites); + } elseif (count($sites) === 1) { + $deets['Site'] = $sites[0]->domain; } + $this->io->displayDeets($deets); $this->io->writeln(''); } diff --git a/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php index 226e524f..cf48dfb6 100644 --- a/app/Traits/SiteHelpersTrait.php +++ b/app/Traits/SiteHelpersTrait.php @@ -4,7 +4,6 @@ namespace Bigpixelrocket\DeployerPHP\Traits; -use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; @@ -14,24 +13,25 @@ /** * Reusable site-related helpers for commands. * - * Requires the using class to extend BaseCommand and have: - * - protected ServerRepository $servers - * - protected SiteRepository $sites - * - protected IOService $io + * Requires classes using this trait to have IOService, ServerRepository, and SiteRepository properties. + * + * @property IOService $io + * @property ServerRepository $servers + * @property SiteRepository $sites */ trait SiteHelpersTrait { /** - * Select a site from inventory by domain option or interactive prompt. + * Display a warning to add a site if no sites are available. Otherwise, return all sites. * - * @return array{site: SiteDTO|null, exit_code: int} Site DTO and exit code (SUCCESS if empty inventory, FAILURE if not found) + * @return array|int Returns array of sites or Command::SUCCESS if no sites available */ - protected function selectSite(string $optionName = 'site', string $promptLabel = 'Select site:'): array + protected function ensureSitesAvailable(): array|int { - // // Get all sites - $allSites = $this->sites->all(); + + // Check if no sites are available if (count($allSites) === 0) { $this->io->warning('No sites found in inventory'); $this->io->writeln([ @@ -40,7 +40,26 @@ protected function selectSite(string $optionName = 'site', string $promptLabel = '', ]); - return ['site' => null, 'exit_code' => Command::SUCCESS]; + return Command::SUCCESS; + } + + return $allSites; + } + + /** + * Select a site from inventory by domain option or interactive prompt. + * + * @return SiteDTO|int Returns SiteDTO on success, or Command::SUCCESS if empty inventory, or Command::FAILURE if not found + */ + protected function selectSite(string $optionName = 'site', string $promptLabel = 'Select site:'): SiteDTO|int + { + // + // Get all sites + + $allSites = $this->ensureSitesAvailable(); + + if (is_int($allSites)) { + return $allSites; } // @@ -64,61 +83,10 @@ protected function selectSite(string $optionName = 'site', string $promptLabel = if ($site === null) { $this->io->error("Site '{$domain}' not found in inventory"); - return ['site' => null, 'exit_code' => Command::FAILURE]; + return Command::FAILURE; } - return ['site' => $site, 'exit_code' => Command::SUCCESS]; - } - - /** - * Multi-select servers from inventory. - * - * Supports both CLI option (comma-separated server names) and interactive multiselect prompt. - * - * @param string $optionName Option name to check for pre-provided values - * @return array Selected server names - */ - protected function selectServers(string $optionName = 'servers'): array - { - // - // Get all servers and extract names - - $allServers = $this->servers->all(); - $serverNames = array_map(fn (ServerDTO $server): string => $server->name, $allServers); - - // - // Get servers via option or prompt - - /** @var string|array $serversInput */ - $serversInput = $this->io->getOptionOrPrompt( - $optionName, - fn (): array => $this->io->promptMultiselect( - label: 'Select servers:', - options: $serverNames, - required: true - ) - ); - - // - // Parse input into array of server names - - if (is_string($serversInput)) { - // Parse comma-separated server names from CLI option - $selectedServers = array_map(trim(...), explode(',', $serversInput)); - - // Validate servers exist - foreach ($selectedServers as $serverName) { - if ($this->servers->findByName($serverName) === null) { - throw new \RuntimeException("Server '{$serverName}' not found in inventory"); - } - } - } else { - // Already an array from interactive prompt - $selectedServers = $serversInput; - } - - // Ensure array values are strings with sequential integer keys - return array_values(array_filter(array_map(strval(...), $selectedServers))); + return $site; } /** @@ -126,19 +94,26 @@ protected function selectServers(string $optionName = 'servers'): array */ protected function displaySiteDeets(SiteDTO $site): void { - $lines = [" Domain: {$site->domain}"]; + $details = ['Domain' => $site->domain]; if ($site->isLocal()) { - $lines[] = " Type: Local"; + $details['Source'] = 'Local'; } else { - $lines[] = " Type: Git"; - $lines[] = " Repo: {$site->repo}"; - $lines[] = " Branch: {$site->branch}"; + $details = [ + ...$details, + 'Source' => 'Git', + 'Repo' => $site->repo, + 'Branch' => $site->branch, + ]; } - $lines[] = " Servers: ".implode(', ', $site->servers).''; - $lines[] = ' '; + if (count($site->servers) > 1) { + $details['Servers'] = $site->servers; + } elseif (count($site->servers) === 1) { + $details['Server'] = $site->servers[0]; + } - $this->io->writeln($lines); + $this->io->displayDeets($details); + $this->io->writeln(''); } } diff --git a/app/Traits/SiteValidationTrait.php b/app/Traits/SiteValidationTrait.php index 8cb3ac3f..98318fd5 100644 --- a/app/Traits/SiteValidationTrait.php +++ b/app/Traits/SiteValidationTrait.php @@ -4,10 +4,18 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; +use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; +use Bigpixelrocket\DeployerPHP\Services\ProcessService; + /** * Validation helpers for site configuration. * - * Requires the using class to extend BaseCommand. + * Requires classes using this trait to have ProcessService, ServerRepository, and SiteRepository properties. + * + * @property ProcessService $proc + * @property ServerRepository $servers + * @property SiteRepository $sites */ trait SiteValidationTrait { From c998fb04851d422e54d984ccadc2786711ed1330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 1 Nov 2025 11:17:48 +0200 Subject: [PATCH 2/3] fix: misleading warning message when server not found while adding site --- app/Console/Site/SiteAddCommand.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 28b19dab..5e984600 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -60,8 +60,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $this->selectServer(); if (is_int($server)) { - $this->io->warning('You must add at least one server before adding a site.'); - $this->io->writeln(''); return $server; } From 595bff45ea95206226f7e9f7a0afc4491e0c232a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 1 Nov 2025 11:24:37 +0200 Subject: [PATCH 3/3] fix: rector issue --- app/Services/IOService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/IOService.php b/app/Services/IOService.php index fb9cda03..03c0306f 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -547,7 +547,7 @@ public function displayDeets(array $details): void } // Find longest key for alignment - $maxLength = max(array_map('strlen', array_keys($details))); + $maxLength = max(array_map(strlen(...), array_keys($details))); $lines = []; foreach ($details as $key => $value) {