diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index b7e1f5a6..83d87481 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -12,6 +12,21 @@ All rules MANDATORY. - Explicit return types with generics: `Collection` - Dependency injection via Symfony patterns - Use Symfony classes over native PHP functions (Filesystem, Process) for testability +- **Yoda conditions:** Always place constants on the left side of comparisons to prevent accidental assignment + +### PHPStan Type Hints + +Use `@var` annotations to help PHPStan understand types it cannot infer, not `assert()` in production code. + +```php +// ✅ CORRECT - @var annotation (zero runtime impact) +/** @var string $apiToken */ +$apiToken = $this->env->get(['API_TOKEN']); + +// ❌ WRONG - assert() in production code (runtime cost, can be disabled) +$apiToken = $this->env->get(['API_TOKEN']); +assert(is_string($apiToken)); +``` ### Imports diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index c23a99f8..d9ef589e 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -34,7 +34,7 @@ $this->info('Configuration loaded'); // Cyan info - Base: BaseCommand.php - Output: ConsoleOutputTrait.php - Input: ConsoleInputTrait.php -- Methods: `writeln()`, `info()`, `hr()`, `h1()`, `success()`, `error()`, `warning()`, `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()`, `showCommandHint()` +- Methods: `writeln()`, `info()`, `hr()`, `h1()`, `success()`, `error()`, `warning()`, `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()`, `showCommandReplay()` **Trait Organization:** @@ -211,10 +211,10 @@ See: ServerDeleteCommand.php, ServerAddCommand.php ### Command Completion -Always call `showCommandHint()` before returning SUCCESS to teach non-interactive usage: +Always call `showCommandReplay()` before returning SUCCESS to teach non-interactive usage: ```php -$this->showCommandHint('command:name', [ +$this->showCommandReplay('command:name', [ 'option1' => $value1, 'option2' => $value2, ]); diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index 16ec8b25..e1010823 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -10,7 +10,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'hello', description: 'Display a friendly hello message')] +#[AsCommand( + name: 'hello', + description: 'Display a friendly hello message' +)] class HelloCommand extends BaseCommand { /** @@ -22,7 +25,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; - $this->io->success('Hello ' . $user . '!'); + $this->yay('Hello ' . $user . '!'); return Command::SUCCESS; } diff --git a/app/Console/Key/KeyAddDigitalOceanCommand.php b/app/Console/Key/KeyAddDigitalOceanCommand.php index ddb8895d..7abe9fd6 100644 --- a/app/Console/Key/KeyAddDigitalOceanCommand.php +++ b/app/Console/Key/KeyAddDigitalOceanCommand.php @@ -5,31 +5,27 @@ namespace Bigpixelrocket\DeployerPHP\Console\Key; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyValidationTrait; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; +use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Add a local SSH public key to the user's DigitalOcean account, - * making it available for droplet provisioning. - */ #[AsCommand( name: 'key:add:digitalocean', description: 'Add a local SSH public key to DigitalOcean' )] class KeyAddDigitalOceanCommand extends BaseCommand { - use DigitalOceanCommandTrait; - use KeyHelpersTrait; - use KeyValidationTrait; + use DigitalOceanTrait; + use KeysTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -41,16 +37,21 @@ protected function configure(): void ->addOption('public-key-path', null, InputOption::VALUE_REQUIRED, 'SSH public key path'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Add SSH Key to DigitalOcean'); + $this->heading('Add SSH Key to DigitalOcean'); + + // + // Retrieve DigitalOcean account data + // ------------------------------------------------------------------------------- if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { return Command::FAILURE; @@ -58,7 +59,61 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Gather key details + // ------------------------------------------------------------------------------- + + $deets = $this->gatherKeyDeets(); + + if ($deets === null) { + return Command::FAILURE; + } + + [ + 'publicKeyPath' => $publicKeyPath, + 'keyName' => $keyName, + ] = $deets; + + // + // Upload public key + // ------------------------------------------------------------------------------- + + try { + $keyId = $this->io->promptSpin( + fn () => $this->digitalOcean->key->uploadPublicKey($publicKeyPath, $keyName), + 'Uploading public SSH key...' + ); + + $this->yay("Public SSH key uploaded successfully (ID: {$keyId})"); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + // + // Show command replay + // ------------------------------------------------------------------------------- + + $this->showCommandReplay('key:add:digitalocean', [ + 'public-key-path' => $publicKeyPath, + 'name' => $keyName, + ]); + + return Command::SUCCESS; + } + + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + /** + * Gather key details from user input or CLI options. + * + * @return array{publicKeyPath: string, keyName: string}|null + */ + protected function gatherKeyDeets(): ?array + { /** @var string|null $publicKeyPathRaw */ $publicKeyPathRaw = $this->io->getValidatedOptionOrPrompt( 'public-key-path', @@ -76,9 +131,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $publicKeyPath = $this->resolvePublicKeyPath($publicKeyPathRaw); if ($publicKeyPath === null) { - $this->io->error('SSH public key not found.'); - - return Command::FAILURE; + $this->nay('SSH public key not found.'); + return null; } $defaultName = 'deployer-key'; @@ -97,35 +151,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if ($keyName === null) { - return Command::FAILURE; + return null; } - // - // Upload SSH key - - try { - $keyId = $this->io->promptSpin( - fn () => $this->digitalOcean->key->uploadKey($publicKeyPath, $keyName), - 'Uploading SSH key...' - ); - - $this->io->success("SSH key uploaded successfully (ID: {$keyId})"); - $this->io->writeln(''); - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); - $this->io->writeln(''); - - return Command::FAILURE; - } - - // - // Show command hint - - $this->io->showCommandHint('key:add:digitalocean', [ - 'public-key-path' => $publicKeyPath, - 'name' => $keyName, - ]); - - return Command::SUCCESS; + return [ + 'publicKeyPath' => $publicKeyPath, + 'keyName' => $keyName, + ]; } } diff --git a/app/Console/Key/KeyDeleteDigitalOceanCommand.php b/app/Console/Key/KeyDeleteDigitalOceanCommand.php index e7768e04..3d5e3dea 100644 --- a/app/Console/Key/KeyDeleteDigitalOceanCommand.php +++ b/app/Console/Key/KeyDeleteDigitalOceanCommand.php @@ -5,28 +5,27 @@ namespace Bigpixelrocket\DeployerPHP\Console\Key; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; +use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Delete a SSH key from the user's DigitalOcean account. - */ #[AsCommand( name: 'key:delete:digitalocean', - description: 'Delete a SSH key from DigitalOcean' + description: 'Delete a public SSH key from DigitalOcean' )] class KeyDeleteDigitalOceanCommand extends BaseCommand { - use DigitalOceanCommandTrait; - use KeyHelpersTrait; + use DigitalOceanTrait; + use KeysTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -34,63 +33,62 @@ protected function configure(): void parent::configure(); $this - ->addOption('key', null, InputOption::VALUE_REQUIRED, 'SSH key ID') - ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing key ID (use with caution)') - ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + ->addOption('key', null, InputOption::VALUE_REQUIRED, 'DigitalOcean public SSH key ID') + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing the key ID to confirm') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip Yes/No confirmation prompt'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Delete SSH Key from DigitalOcean'); - - if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { - return Command::FAILURE; - } + $this->heading('Delete a public SSH key from DigitalOcean'); // - // Fetch available keys - - try { - $availableKeys = $this->digitalOcean->account->getUserSshKeys(); - } catch (\RuntimeException $e) { - $this->io->error('Failed to fetch SSH keys: ' . $e->getMessage()); - $this->io->writeln(''); + // Retrieve DigitalOcean account data + // ------------------------------------------------------------------------------- + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { return Command::FAILURE; } // // Select key + // ------------------------------------------------------------------------------- - $selection = $this->selectKey($availableKeys); + $selectedKey = $this->selectKey(); - if ($selection['key'] === null) { - return $selection['exit_code']; + if (is_int($selectedKey)) { + return Command::FAILURE; } - $keyId = (int) $selection['key']; - $keyDescription = $availableKeys[$keyId]; + [ + 'id' => $keyId, + 'description' => $keyDescription, + ] = $selectedKey; // - // Display key details + // Display key + // ------------------------------------------------------------------------------- $this->io->hr(); - $this->io->writeln([ - " ID: {$keyId}", - " Name: {$keyDescription}", - '', + $this->io->displayDeets([ + 'ID' => (string) $keyId, + 'Name' => $keyDescription, ]); + $this->io->writeln(''); + // // Confirm deletion with extra safety + // ------------------------------------------------------------------------------- /** @var bool $forceSkip */ $forceSkip = $input->getOption('force') ?? false; @@ -104,8 +102,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if ($typedKeyId !== (string) $keyId) { - $this->io->error('Key ID does not match. Deletion cancelled.'); - $this->io->writeln(''); + $this->nay('Key ID does not match. Deletion cancelled.'); return Command::FAILURE; } @@ -121,7 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if (!$confirmed) { - $this->io->warning('Cancelled deleting SSH key'); + $this->io->warning('Cancelled deleting public SSH key'); $this->io->writeln(''); return Command::SUCCESS; @@ -129,26 +126,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Delete key + // ------------------------------------------------------------------------------- try { $this->io->promptSpin( - fn () => $this->digitalOcean->key->deleteKey($keyId), - 'Deleting SSH key...' + fn () => $this->digitalOcean->key->deletePublicKey((int) $keyId), + 'Deleting public SSH key...' ); - $this->io->success('SSH key deleted successfully'); - $this->io->writeln(''); + $this->yay('Public SSH key deleted successfully'); } catch (\RuntimeException $e) { - $this->io->error('Failed to delete SSH key: ' . $e->getMessage()); - $this->io->writeln(''); + $this->nay('Failed to delete public SSH key: ' . $e->getMessage()); return Command::FAILURE; } // - // Show command hint + // Show command replay + // ------------------------------------------------------------------------------- - $this->io->showCommandHint('key:delete:digitalocean', [ + $this->showCommandReplay('key:delete:digitalocean', [ 'key' => (string) $keyId, 'yes' => $confirmed, 'force' => true, @@ -156,4 +153,5 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + } diff --git a/app/Console/Key/KeyListDigitalOceanCommand.php b/app/Console/Key/KeyListDigitalOceanCommand.php index 5b2cb9f3..803d224e 100644 --- a/app/Console/Key/KeyListDigitalOceanCommand.php +++ b/app/Console/Key/KeyListDigitalOceanCommand.php @@ -5,51 +5,43 @@ namespace Bigpixelrocket\DeployerPHP\Console\Key; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * List SSH keys in the user's DigitalOcean account. - */ #[AsCommand( name: 'key:list:digitalocean', - description: 'List SSH keys in DigitalOcean' + description: 'List public SSH keys in DigitalOcean' )] class KeyListDigitalOceanCommand extends BaseCommand { - use DigitalOceanCommandTrait; + use DigitalOceanTrait; + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('List SSH Keys in DigitalOcean'); + $this->heading('List Public SSH Keys in DigitalOcean'); + + // + // Retrieve DigitalOcean account data + // ------------------------------------------------------------------------------- if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { return Command::FAILURE; } - // - // Fetch SSH keys - // ------------------------------------------------------------------------------- - - try { - $keys = $this->io->promptSpin( - fn () => $this->digitalOcean->account->getUserSshKeys(), - 'Fetching SSH keys...' - ); - } catch (\RuntimeException $e) { - $this->io->error('Failed to fetch SSH keys: ' . $e->getMessage()); - $this->io->writeln(''); + $keys = $this->ensureKeysAvailable(); + if (is_int($keys)) { return Command::FAILURE; } @@ -57,17 +49,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Display keys // ------------------------------------------------------------------------------- - if (count($keys) === 0) { - $this->io->warning('No SSH keys found in your DigitalOcean account'); - $this->io->writeln([ - '', - 'Use key:add:digitalocean to add an SSH key', - '', - ]); - - return Command::SUCCESS; - } - foreach ($keys as $keyId => $description) { $this->io->writeln(" {$keyId} - {$description}"); } diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index 314ec717..c33124f1 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -6,26 +6,27 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyValidationTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerValidationTrait; +use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'server:add', description: 'Add a new server to the inventory')] +#[AsCommand( + name: 'server:add', + description: 'Add a new server to the inventory' +)] class ServerAddCommand extends BaseCommand { - use KeyHelpersTrait; - use KeyValidationTrait; - use ServerHelpersTrait; - use ServerValidationTrait; + use KeysTrait; + use ServersTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -40,20 +41,94 @@ protected function configure(): void ->addOption('username', null, InputOption::VALUE_REQUIRED, 'SSH username (default: root)'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Add New Server'); + $this->heading('Add New Server'); // // Gather server details + // ------------------------------------------------------------------------------- + + $deets = $this->gatherServerDeets(); + + if ($deets === null) { + return Command::FAILURE; + } + + [ + 'name' => $name, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'privateKeyPath' => $privateKeyPath, + ] = $deets; + + // + // Display server details + // ------------------------------------------------------------------------------- + + $server = new ServerDTO( + name: $name, + host: $host, + port: $port, + username: $username, + privateKeyPath: $privateKeyPath + ); + + $this->displayServerDeets($server); + + // + // Verify SSH connection & add to inventory + // ------------------------------------------------------------------------------- + + $this->verifySSHConnection($server); // SSH failure is not a blocker + + try { + $this->servers->create($server); + } catch (\RuntimeException $e) { + $this->nay('Failed to add server to inventory: ' . $e->getMessage()); + + return Command::FAILURE; + } + + $this->yay('Server added to inventory'); + + // + // Show command replay + // ------------------------------------------------------------------------------- + + $this->showCommandReplay('server:add', [ + 'name' => $name, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'private-key-path' => $privateKeyPath, + ]); + + return Command::SUCCESS; + } + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + + /** + * Gather server details from user input or CLI options. + * + * @return array{name: string, host: string, port: int, username: string, privateKeyPath: string}|null + */ + protected function gatherServerDeets(): ?array + { /** @var string|null $name */ $name = $this->io->getValidatedOptionOrPrompt( 'name', @@ -63,11 +138,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateNameInput($value) + fn ($value) => $this->validateServerName($value) ); if ($name === null) { - return Command::FAILURE; + return null; } /** @var string|null $host */ @@ -79,11 +154,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateHostInput($value) + fn ($value) => $this->validateServerHost($value) ); if ($host === null) { - return Command::FAILURE; + return null; } /** @var string|null $portString */ @@ -95,11 +170,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validatePortInput($value) + fn ($value) => $this->validateServerPort($value) ); if ($portString === null) { - return Command::FAILURE; + return null; } $port = (int) $portString; @@ -129,54 +204,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $privateKeyPath = $this->resolvePrivateKeyPath($privateKeyPathRaw); if ($privateKeyPath === null) { - $this->io->error('SSH private key not found.'); - $this->io->writeln(''); - - return Command::FAILURE; - } - - // - // Create DTO and display server info - - $server = new ServerDTO( - name: $name, - host: $host, - port: $port, - username: $username, - privateKeyPath: $privateKeyPath - ); - - $this->io->hr(); - - $this->displayServerDeets($server); - $this->io->writeln(''); - - // - // Save to repository - - try { - $this->servers->create($server); - } catch (\RuntimeException $e) { - $this->io->error('Failed to add server: ' . $e->getMessage()); + $this->nay('SSH private key not found.'); - return Command::FAILURE; + return null; } - $this->io->success('Server added successfully'); - $this->io->writeln(''); - - // - // Show command hint - - $this->io->showCommandHint('server:add', [ + return [ 'name' => $name, 'host' => $host, 'port' => $port, 'username' => $username, - 'private-key-path' => $privateKeyPath, - ]); - - return Command::SUCCESS; + 'privateKeyPath' => $privateKeyPath, + ]; } - } diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index 66062866..a33d86eb 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -5,22 +5,27 @@ namespace Bigpixelrocket\DeployerPHP\Console\Server; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'server:delete', description: 'Delete a server from the inventory')] +#[AsCommand( + name: 'server:delete', + description: 'Delete a server from the inventory' +)] class ServerDeleteCommand extends BaseCommand { - use DigitalOceanCommandTrait; - use ServerHelpersTrait; + use DigitalOceanTrait; + use ServersTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -29,23 +34,25 @@ protected function configure(): void $this ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name') - ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing server name (use with caution)') - ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing the server name to confirm') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip Yes/No confirmation prompt'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Delete Server'); + $this->heading('Delete Server'); // - // Select server + // Select server & display details + // ------------------------------------------------------------------------------- $server = $this->selectServer(); @@ -53,44 +60,45 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - // Get sites for this server - $serverSites = $this->sites->findByServer($server->name); + $this->displayServerDeets($server); // - // Display server details - - $this->io->hr(); + // Check if server has sites + // ------------------------------------------------------------------------------- - $this->displayServerDeets($server, $serverSites); - $this->io->writeln(''); + $serverSites = $this->sites->findByServer($server->name); if (count($serverSites) > 0) { - $this->io->error("Cannot delete server '{$server->name}' because it has one or more sites."); + $this->io->warning("Cannot delete server '{$server->name}' because it has one or more sites."); + $this->io->writeln([ + '', + 'Use site:delete to delete the sites first.', + '', + ]); return Command::FAILURE; } // - // Check if DigitalOcean server and initialize API + // Initialize provider API + // ------------------------------------------------------------------------------- $isDigitalOceanServer = $this->isDigitalOceanServer($server); + if ($isDigitalOceanServer && Command::FAILURE === $this->initializeDigitalOceanAPI()) { + $this->nay('Cannot delete server: DigitalOcean API authentication failed.'); + $this->io->writeln([ + '', + 'You must authenticate with DigitalOcean to delete provisioned servers.', + 'The server will not be removed from inventory to prevent orphaned cloud resources.', + '', + ]); - if ($isDigitalOceanServer) { - if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { - $this->io->error('Cannot delete server: DigitalOcean API authentication failed.'); - $this->io->writeln([ - '', - 'You must authenticate with DigitalOcean to delete provisioned servers.', - 'The server will not be removed from inventory to prevent orphaned cloud resources.', - '', - ]); - - return Command::FAILURE; - } + return Command::FAILURE; } // // Display warning for cloud provider servers + // ------------------------------------------------------------------------------- if ($isDigitalOceanServer) { $this->io->writeln('⚠ This is a DigitalOcean server.'); @@ -104,6 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Confirm deletion with extra safety + // ------------------------------------------------------------------------------- /** @var bool $forceSkip */ $forceSkip = $input->getOption('force') ?? false; @@ -115,8 +124,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if ($typedName !== $server->name) { - $this->io->error('Server name does not match. Deletion cancelled.'); - $this->io->writeln(''); + $this->nay('Server name does not match. Deletion cancelled.'); return Command::FAILURE; } @@ -140,6 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Destroy cloud provider resources + // ------------------------------------------------------------------------------- if ($isDigitalOceanServer && $server->dropletId !== null) { try { @@ -147,9 +156,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int fn () => $this->digitalOcean->droplet->destroyDroplet($server->dropletId), "Destroying droplet (ID: {$server->dropletId})" ); - $this->io->success('Droplet destroyed'); + + $this->yay('Droplet destroyed (ID: ' . $server->dropletId . ')'); } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); + $this->nay($e->getMessage()); $this->io->writeln(''); $continueAnyway = $this->io->promptConfirm( @@ -165,16 +175,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Delete server from inventory + // ------------------------------------------------------------------------------- $this->servers->delete($server->name); - $this->io->success("Server '{$server->name}' deleted successfully"); - $this->io->writeln(''); + $this->yay("Server '{$server->name}' deleted successfully"); // - // Show command hint + // Show command replay + // ------------------------------------------------------------------------------- - $this->io->showCommandHint('server:delete', [ + $this->showCommandReplay('server:delete', [ 'server' => $server->name, 'yes' => $confirmed, 'force' => true, diff --git a/app/Console/Server/ServerInfoCommand.php b/app/Console/Server/ServerInfoCommand.php index 66f45d3e..428458e3 100644 --- a/app/Console/Server/ServerInfoCommand.php +++ b/app/Console/Server/ServerInfoCommand.php @@ -5,22 +5,28 @@ namespace Bigpixelrocket\DeployerPHP\Console\Server; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerInfoTrait; +use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Traits\PlaybooksTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'server:info', description: 'Display server information')] +#[AsCommand( + name: 'server:info', + description: 'Display server information' +)] class ServerInfoCommand extends BaseCommand { - use ServerHelpersTrait; - use ServerInfoTrait; + use ServersTrait; + use PlaybooksTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -30,19 +36,21 @@ protected function configure(): void $this->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Server Information'); + $this->heading('Server Information'); // - // Select server + // Select server & display details + // ------------------------------------------------------------------------------- $server = $this->selectServer(); @@ -50,18 +58,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $server; } - // Get sites for this server - $serverSites = $this->sites->findByServer($server->name); + $this->displayServerDeets($server); // - // Display server details - - $this->io->hr(); - - $this->displayServerDeets($server, $serverSites); - - // - // Display server information + // Get and display server information + // ------------------------------------------------------------------------------- $info = $this->getServerInfo($server); @@ -69,17 +70,85 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $info; } - $this->io->writeln(''); $this->displayServerInfo($info); // - // Show command hint + // Show command replay + // ------------------------------------------------------------------------------- - $this->io->showCommandHint('server:info', [ + $this->showCommandReplay('server:info', [ 'server' => $server->name, ]); return Command::SUCCESS; } + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + + /** + * Get server information by executing server-info playbook. + * + * @param ServerDTO $server Server to get information for + * @return array|int Returns parsed server info or failure code on failure + */ + protected function getServerInfo(ServerDTO $server): array|int + { + return $this->executePlaybook( + $server, + 'server-info', + 'Retrieving server information...', + ); + } + + /** + * Display formatted server information. + * + * @param array $info + */ + protected function displayServerInfo(array $info): void + { + $distroName = match ($info['distro'] ?? 'unknown') { + 'debian' => 'Debian/Ubuntu', + 'redhat' => 'RedHat/CentOS/Fedora', + 'amazon' => 'Amazon Linux', + default => 'Unknown', + }; + + $permissionsText = match ($info['permissions'] ?? 'none') { + 'root' => 'root', + 'sudo' => 'sudo', + default => 'insufficient', + }; + + $deets = [ + 'Distro' => $distroName, + 'User' => $permissionsText, + ]; + + $this->io->displayDeets($deets); + $this->io->writeln(''); + + $services = []; + + // Add listening ports if any + if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { + $portsList = []; + foreach ($info['ports'] as $port => $process) { + if (is_numeric($port) && is_string($process)) { + $portsList[] = "Port {$port}: {$process}"; + } + } + if (count($portsList) > 0) { + $services = $portsList; + } + } + + $this->io->displayDeets(['Services' => $services]); + $this->io->writeln(''); + } + } diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php index 53326c30..d9162859 100644 --- a/app/Console/Server/ServerListCommand.php +++ b/app/Console/Server/ServerListCommand.php @@ -5,32 +5,37 @@ namespace Bigpixelrocket\DeployerPHP\Console\Server; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand(name: 'server:list', description: 'List servers in the inventory')] +#[AsCommand( + name: 'server:list', + description: 'List servers in the inventory' +)] class ServerListCommand extends BaseCommand { - use ServerHelpersTrait; - use SiteHelpersTrait; + use ServersTrait; + use SitesTrait; + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('List Servers'); + $this->heading('List Servers'); // // Get all servers + // ------------------------------------------------------------------------------- $allServers = $this->ensureServersAvailable(); @@ -39,12 +44,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // - // Display servers with their sites + // Display servers + // ------------------------------------------------------------------------------- foreach ($allServers as $count => $server) { - // Display server with sites - $serverSites = $this->sites->findByServer($server->name); - $this->displayServerDeets($server, $serverSites); + $this->displayServerDeets($server); if ($count < count($allServers) - 1) { $this->io->writeln([ diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index 8f10513e..887fc77d 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -6,12 +6,9 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; -use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanValidationTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\KeyValidationTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\ServerValidationTrait; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanTrait; +use Bigpixelrocket\DeployerPHP\Traits\KeysTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -24,15 +21,14 @@ )] class ServerProvisionDigitalOceanCommand extends BaseCommand { - use DigitalOceanCommandTrait; - use DigitalOceanValidationTrait; - use ServerHelpersTrait; - use ServerValidationTrait; - use KeyHelpersTrait; - use KeyValidationTrait; + use DigitalOceanTrait; + use ServersTrait; + use KeysTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -52,27 +48,29 @@ protected function configure(): void ->addOption('monitoring', null, InputOption::VALUE_NONE, 'Enable monitoring'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Provision DigitalOcean Droplet'); + $this->heading('Provision DigitalOcean Droplet'); + + // + // Retrieve DigitalOcean account data + // ------------------------------------------------------------------------------- if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { return Command::FAILURE; } - // - // Retrieve DigitalOcean data - $accountData = $this->io->promptSpin( fn () => [ - 'keys' => $this->digitalOcean->account->getUserSshKeys(), + 'keys' => $this->digitalOcean->account->getPublicKeys(), 'regions' => $this->digitalOcean->account->getAvailableRegions(), 'sizes' => $this->digitalOcean->account->getAvailableSizes(), 'images' => $this->digitalOcean->account->getAvailableImages(), @@ -80,21 +78,157 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'Retrieving account information...' ); - if (count($accountData['keys']) === 0) { - $this->io->warning('No SSH keys found in your DigitalOcean account'); - $this->io->writeln([ - '', - 'You must add at least one SSH key before provisioning a server.', - 'Run key:add:digitalocean to add an SSH key.', - '', - ]); + $keys = $this->ensureKeysAvailable($accountData['keys']); + + if ($keys === Command::FAILURE) { + return Command::FAILURE; + } + + // + // Gather provisioning details + // ------------------------------------------------------------------------------- + + $deets = $this->gatherProvisioningDeets($accountData); + + if ($deets === null) { + return Command::FAILURE; + } + + [ + 'name' => $name, + 'region' => $region, + 'size' => $size, + 'image' => $image, + 'sshKeyId' => $sshKeyId, + 'sshKeyIds' => $sshKeyIds, + 'privateKeyPath' => $privateKeyPath, + 'backups' => $backups, + 'monitoring' => $monitoring, + 'ipv6' => $ipv6, + 'vpcUuid' => $vpcUuid, + ] = $deets; + + // + // Provision droplet + // ------------------------------------------------------------------------------- + + try { + $dropletData = $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->createDroplet( + name: $name, + region: $region, + size: $size, + image: $image, + sshKeys: $sshKeyIds, + backups: $backups, + monitoring: $monitoring, + ipv6: $ipv6, + vpcUuid: $vpcUuid + ), + 'Provisioning droplet...' + ); + + $dropletId = $dropletData['id']; + $this->yay("Droplet provisioned (ID: {$dropletId})"); + } catch (\RuntimeException $e) { + $this->nay('Failed to provision droplet: ' . $e->getMessage()); return Command::FAILURE; } // - // Gather droplet configuration + // Wait for droplet to become active + // ------------------------------------------------------------------------------- + + try { + $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->waitForDropletReady($dropletId), + 'Waiting for droplet to become active...' + ); + $this->yay('Droplet is active'); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + $this->rollbackDroplet($dropletId); + + return Command::FAILURE; + } + + // + // Get droplet IP address & display server details + // ------------------------------------------------------------------------------- + + try { + $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); + } catch (\RuntimeException $e) { + $this->nay('Failed to get droplet IP address: ' . $e->getMessage()); + $this->rollbackDroplet($dropletId); + + return Command::FAILURE; + } + + $server = new ServerDTO( + name: $name, + host: $ipAddress, + port: 22, + username: 'root', + privateKeyPath: $privateKeyPath, + provider: 'digitalocean', + dropletId: $dropletId + ); + + $this->displayServerDeets($server); + + // + // Verify SSH connection & add to inventory + // ------------------------------------------------------------------------------- + + $this->verifySSHConnection($server); // SSH failure is not a blocker + + try { + $this->servers->create($server); + } catch (\RuntimeException $e) { + $this->nay('Failed to add server to inventory: ' . $e->getMessage()); + $this->rollbackDroplet($dropletId); + + return Command::FAILURE; + } + + $this->yay('Server added to inventory'); + + // + // Show command replay + // ------------------------------------------------------------------------------- + + $this->showCommandReplay('server:provision:digitalocean', [ + 'name' => $name, + 'region' => $region, + 'size' => $size, + 'image' => $image, + 'ssh-key-id' => $sshKeyId, + 'backups' => $backups, + 'monitoring' => $monitoring, + 'ipv6' => $ipv6, + 'vpc-uuid' => $vpcUuid, + ]); + + return Command::SUCCESS; + } + + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + + /** + * Gather provisioning details from user input or CLI options. + * + * @param array{keys: array, regions: array, sizes: array, images: array} $accountData + * @return array{name: string, region: string, size: string, image: string, sshKeyId: int, sshKeyIds: array, privateKeyPath: string, backups: bool, monitoring: bool, ipv6: bool, vpcUuid: string|null}|null + */ + protected function gatherProvisioningDeets(array $accountData): ?array + { /** @var string|null $name */ $name = $this->io->getValidatedOptionOrPrompt( 'name', @@ -104,11 +238,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateNameInput($value) + fn ($value) => $this->validateServerName($value) ); if ($name === null) { - return Command::FAILURE; + return null; } /** @var string|null $region */ @@ -119,11 +253,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int options: $accountData['regions'], hint: 'Choose the datacenter location' ), - fn ($value) => $this->validateRegionInput($value, $accountData['regions']) + fn ($value) => $this->validateDigitalOceanRegion($value, $accountData['regions']) ); if ($region === null) { - return Command::FAILURE; + return null; } /** @var string|null $size */ @@ -134,11 +268,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int options: $accountData['sizes'], hint: 'Choose CPU, RAM, and storage' ), - fn ($value) => $this->validateSizeInput($value, $accountData['sizes']) + fn ($value) => $this->validateDigitalOceanDropletSize($value, $accountData['sizes']) ); if ($size === null) { - return Command::FAILURE; + return null; } /** @var string|null $image */ @@ -149,29 +283,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int options: $accountData['images'], hint: 'Ubuntu and Debian only' ), - fn ($value) => $this->validateImageInput($value, $accountData['images']) + fn ($value) => $this->validateDigitalOceanDropletImage($value, $accountData['images']) ); if ($image === null) { - return Command::FAILURE; + return null; } // // Select SSH key + /** @var array $keys */ + $keys = $accountData['keys']; + /** @var int|string|null $selectedKey */ $selectedKey = $this->io->getValidatedOptionOrPrompt( 'ssh-key-id', fn ($validate) => $this->io->promptSelect( - label: 'Select SSH key for droplet access:', + label: 'Select public SSH key for droplet access:', options: $accountData['keys'], validate: $validate ), - fn (mixed $value): ?string => $this->validateSshKeyInput($value, $accountData['keys']) + fn (mixed $value): ?string => $this->validateDigitalOceanSSHKey($value, $keys) ); if ($selectedKey === null) { - return Command::FAILURE; + return null; } // Convert to integer if string was provided @@ -199,10 +336,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $privateKeyPath = $this->resolvePrivateKeyPath($privateKeyPathRaw); if ($privateKeyPath === null) { - $this->io->error('SSH private key not found.'); - $this->io->writeln(''); + $this->nay('SSH private key not found.'); - return Command::FAILURE; + return null; } // @@ -246,11 +382,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int options: $this->digitalOcean->account->getUserVpcs($region), hint: 'Virtual Private Cloud for network isolation' ), - fn ($value) => $this->validateVpcUuidInput($value) + fn ($value) => $this->validateDigitalOceanVPCUUID($value) ); if ($vpcUuid === null) { - return Command::FAILURE; + return null; } // Convert "default" to null for API @@ -258,154 +394,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $vpcUuid = null; } - // - // Display provisioning summary - - $this->io->hr(); - - $this->io->writeln([ - " Name: {$name}", - " Region: {$region}", - " Size: {$size}", - " Image: {$image}", - " SSH Key: {$accountData['keys'][$sshKeyId]}", - ' Backups: ' . ($backups ? 'enabled' : 'disabled') . '', - ' Monitoring: ' . ($monitoring ? 'enabled' : 'disabled') . '', - ' IPv6: ' . ($ipv6 ? 'enabled' : 'disabled') . '', - ' VPC: ' . ($vpcUuid ?? 'default') . '', - '', - ]); - - // - // Create droplet - - try { - $dropletData = $this->io->promptSpin( - fn () => $this->digitalOcean->droplet->createDroplet( - name: $name, - region: $region, - size: $size, - image: $image, - sshKeys: $sshKeyIds, - backups: $backups, - monitoring: $monitoring, - ipv6: $ipv6, - vpcUuid: $vpcUuid - ), - 'Creating droplet...' - ); - - $dropletId = $dropletData['id']; - $this->io->success("Droplet created (ID: {$dropletId})"); - } catch (\RuntimeException $e) { - $this->io->error('Failed to create droplet: ' . $e->getMessage()); - - return Command::FAILURE; - } - - // - // Wait for droplet to become active - - $this->io->writeln(''); - - try { - $this->io->promptSpin( - fn () => $this->digitalOcean->droplet->waitForDropletReady($dropletId), - 'Waiting for droplet to become active...' - ); - - $this->io->success('Droplet is now active'); - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); - $this->rollbackDroplet($dropletId); - - return Command::FAILURE; - } - - // - // Get droplet IP address - - try { - $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); - $this->io->writeln(''); - $this->io->writeln(" Public IP: {$ipAddress}"); - } catch (\RuntimeException $e) { - $this->io->error('Failed to retrieve IP address: ' . $e->getMessage()); - $this->rollbackDroplet($dropletId); - - return Command::FAILURE; - } - - // - // Add to inventory - - $server = new ServerDTO( - name: $name, - host: $ipAddress, - port: 22, - username: 'root', - privateKeyPath: $privateKeyPath, - provider: 'digitalocean', - dropletId: $dropletId - ); - - try { - $this->servers->create($server); - } catch (\RuntimeException $e) { - $this->io->error('Failed to add server to inventory: ' . $e->getMessage()); - $this->rollbackDroplet($dropletId); - - return Command::FAILURE; - } - - $this->io->writeln(''); - $this->io->success('Server added to inventory'); - $this->io->writeln(''); - - // - // Display server details - // ------------------------------------------------------------------------------- - - $this->displayServerDeets($server); - - // - // Show command hint - // ------------------------------------------------------------------------------- - - $this->io->showCommandHint('server:provision:digitalocean', [ + return [ 'name' => $name, 'region' => $region, 'size' => $size, 'image' => $image, - 'ssh-key-id' => $sshKeyId, + 'sshKeyId' => $sshKeyId, + 'sshKeyIds' => $sshKeyIds, + 'privateKeyPath' => $privateKeyPath, 'backups' => $backups, 'monitoring' => $monitoring, 'ipv6' => $ipv6, - 'vpc-uuid' => $vpcUuid, - ]); - - return Command::SUCCESS; + 'vpcUuid' => $vpcUuid, + ]; } - // - // Rollback - // ------------------------------------------------------------------------------- - /** * Destroy a droplet after failed provisioning. * * @param int $dropletId The droplet ID to destroy */ - private function rollbackDroplet(int $dropletId): void + protected function rollbackDroplet(int $dropletId): void { try { - $this->io->writeln(''); $this->io->promptSpin( fn () => $this->digitalOcean->droplet->destroyDroplet($dropletId), - 'Destroying droplet...' + 'Rolling back droplet...' ); - $this->io->warning('Rolled back provisioning of droplet.'); + $this->io->warning('Rolled back droplet'); } catch (\Throwable $cleanupError) { $this->io->warning($cleanupError->getMessage()); } diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index f5df36aa..75d5a15f 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -5,30 +5,26 @@ namespace Bigpixelrocket\DeployerPHP\Console\Site; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; -use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; -use Bigpixelrocket\DeployerPHP\Traits\SiteValidationTrait; +use Bigpixelrocket\DeployerPHP\Traits\ServersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Add and register a new site to the inventory. - * - * Prompts for site details and saves to inventory. - */ #[AsCommand(name: 'site:add', description: 'Add a new site to the inventory')] class SiteAddCommand extends BaseCommand { - use ServerHelpersTrait; - use SiteHelpersTrait; - use SiteValidationTrait; + use ServersTrait; + use SitesTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -43,28 +39,109 @@ protected function configure(): void ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Add New Site'); + $this->heading('Add New Site'); + + // + // Gather site details + // ------------------------------------------------------------------------------- + + $deets = $this->gatherSiteDeets(); + + if ($deets === null) { + return Command::FAILURE; + } + + [ + 'domain' => $domain, + 'siteSource' => $siteSource, + 'repo' => $repo, + 'branch' => $branch, + 'server' => $server, + ] = $deets; + + // + // Display site details + // ------------------------------------------------------------------------------- + + $site = new SiteDTO( + domain: $domain, + repo: $repo, + branch: $branch, + servers: [$server->name] + ); + + $this->displaySiteDeets($site); + + // + // Save to inventory + // ------------------------------------------------------------------------------- + + try { + $this->sites->create($site); + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + $this->yay('Site added to inventory'); + + // + // Show command replay + // ------------------------------------------------------------------------------- + + $hintOptions = [ + 'domain' => $domain, + 'source' => $siteSource, + 'server' => $server->name, + ]; + + if ($siteSource !== 'local') { + $hintOptions['repo'] = $repo; + $hintOptions['branch'] = $branch; + } + + $this->showCommandReplay('site:add', $hintOptions); + + return Command::SUCCESS; + } + + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + /** + * Gather site details from user input or CLI options. + * + * @return array{domain: string, siteSource: string, repo: ?string, branch: ?string, server: ServerDTO}|null + */ + protected function gatherSiteDeets(): ?array + { // // Select server + // ------------------------------------------------------------------------------- $server = $this->selectServer(); if (is_int($server)) { - return $server; + return null; } // // Gather site details + // ------------------------------------------------------------------------------- /** @var string|null $domain */ $domain = $this->io->getValidatedOptionOrPrompt( @@ -75,15 +152,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateDomainInput($value) + fn ($value) => $this->validateSiteDomain($value) ); if ($domain === null) { - return Command::FAILURE; + return null; } // // Select site source + // ------------------------------------------------------------------------------- /** @var string $siteSource */ $siteSource = $this->io->getOptionOrPrompt( @@ -99,6 +177,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Gather git-specific details + // ------------------------------------------------------------------------------- $repo = null; $branch = null; @@ -116,11 +195,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateRepoInput($value) + fn ($value) => $this->validateSiteRepo($value) ); if ($repo === null) { - return Command::FAILURE; + return null; } $defaultBranch = $this->git->detectCurrentBranch() ?? 'main'; @@ -135,59 +214,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int required: true, validate: $validate ), - fn ($value) => $this->validateBranchInput($value) + fn ($value) => $this->validateSiteBranch($value) ); if ($branch === null) { - return Command::FAILURE; + return null; } } - // - // Create DTO and display site info - - $site = new SiteDTO( - domain: $domain, - repo: $repo, - branch: $branch, - servers: [$server->name] - ); - - $this->io->hr(); - - $this->displaySiteDeets($site); - $this->io->writeln(''); - - // - // Save to repository - - try { - $this->sites->create($site); - } catch (\RuntimeException $e) { - $this->io->error('Failed to add site: ' . $e->getMessage()); - - return Command::FAILURE; - } - - $this->io->success('Site added successfully'); - $this->io->writeln(''); - - // - // Show command hint - - $hintOptions = [ + return [ 'domain' => $domain, - 'source' => $siteSource, - 'server' => $server->name, + 'siteSource' => $siteSource, + 'repo' => $repo, + 'branch' => $branch, + 'server' => $server, ]; - - if (!$isLocal) { - $hintOptions['repo'] = $repo; - $hintOptions['branch'] = $branch; - } - - $this->io->showCommandHint('site:add', $hintOptions); - - return Command::SUCCESS; } } diff --git a/app/Console/Site/SiteDeleteCommand.php b/app/Console/Site/SiteDeleteCommand.php index a84ce9c4..f1449b54 100644 --- a/app/Console/Site/SiteDeleteCommand.php +++ b/app/Console/Site/SiteDeleteCommand.php @@ -5,23 +5,22 @@ namespace Bigpixelrocket\DeployerPHP\Console\Site; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Delete a site from the inventory. - */ #[AsCommand(name: 'site:delete', description: 'Delete a site from the inventory')] class SiteDeleteCommand extends BaseCommand { - use SiteHelpersTrait; + use SitesTrait; + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- protected function configure(): void @@ -30,50 +29,51 @@ 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'); + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing the site domain to confirm') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip Yes/No confirmation prompt'); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('Delete Site'); + $this->heading('Delete Site'); // - // Select site + // Select site & display details + // ------------------------------------------------------------------------------- $site = $this->selectSite(); - if (!$site instanceof \Bigpixelrocket\DeployerPHP\DTOs\SiteDTO) { + if (is_int($site)) { return $site; } - $this->io->hr(); - $this->displaySiteDeets($site); - $this->io->writeln(''); // // Confirm deletion with extra safety + // ------------------------------------------------------------------------------- /** @var bool $forceSkip */ $forceSkip = $input->getOption('force') ?? false; if (!$forceSkip) { + $this->io->writeln(''); + $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(''); + $this->nay('Site domain does not match. Deletion cancelled.'); return Command::FAILURE; } @@ -96,17 +96,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // - // Delete site + // Delete site from inventory + // ------------------------------------------------------------------------------- $this->sites->delete($site->domain); - $this->io->success("Site '{$site->domain}' deleted successfully"); - $this->io->writeln(''); + $this->yay("Site '{$site->domain}' deleted successfully"); // - // Show command hint + // Show command replay + // ------------------------------------------------------------------------------- - $this->io->showCommandHint('site:delete', [ + $this->showCommandReplay('site:delete', [ 'site' => $site->domain, 'yes' => $confirmed, 'force' => true, diff --git a/app/Console/Site/SiteListCommand.php b/app/Console/Site/SiteListCommand.php index a3dc2e4c..0557a8d7 100644 --- a/app/Console/Site/SiteListCommand.php +++ b/app/Console/Site/SiteListCommand.php @@ -5,33 +5,35 @@ namespace Bigpixelrocket\DeployerPHP\Console\Site; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SitesTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * List all sites in the inventory. - */ -#[AsCommand(name: 'site:list', description: 'List sites in the inventory')] +#[AsCommand( + name: 'site:list', + description: 'List sites in the inventory' +)] class SiteListCommand extends BaseCommand { - use SiteHelpersTrait; + use SitesTrait; + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->io->hr(); - $this->io->h1('List Sites'); + $this->heading('List Sites'); // // Get all sites + // ------------------------------------------------------------------------------- $allSites = $this->ensureSitesAvailable(); @@ -41,6 +43,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Display sites + // ------------------------------------------------------------------------------- foreach ($allSites as $count => $site) { $this->displaySiteDeets($site); diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index ad35875c..121be9d4 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -58,8 +58,10 @@ public function __construct( parent::__construct(); } + // ------------------------------------------------------------------------------- // // Configuration + // // ------------------------------------------------------------------------------- /** @@ -126,8 +128,10 @@ protected function initialize(InputInterface $input, OutputInterface $output): v $this->sites->loadInventory($this->inventory); } + // ------------------------------------------------------------------------------- // // Execution + // // ------------------------------------------------------------------------------- /** @@ -155,4 +159,77 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + // ------------------------------------------------------------------------------- + // + // Helper Methods + // + // ------------------------------------------------------------------------------- + + /** + * Display a heading with a horizontal rule and title. + */ + protected function heading(string $text): void + { + $this->io->hr(); + $this->io->h1($text); + } + + /** + * Display a success message. + */ + protected function yay(string $message): void + { + $this->io->success($message); + $this->io->writeln(''); + } + + /** + * Display an error message. + */ + protected function nay(string $message): void + { + $this->io->error($message); + $this->io->writeln(''); + } + + /** + * Display a command replay hint showing how to run non-interactively. + * + * @param array $options Array of option name => value pairs + */ + protected function showCommandReplay(string $commandName, array $options): void + { + // + // Build command options + + $parts = []; + foreach ($options as $optionName => $value) { + if ($value === null || $value === '') { + continue; + } + + // Format the option + $optionFlag = '--'.$optionName; + if (is_bool($value)) { + if ($value) { + $parts[] = $optionFlag; + } + } else { + $stringValue = is_scalar($value) ? (string) $value : ''; + $escapedValue = escapeshellarg($stringValue); + $parts[] = "{$optionFlag}={$escapedValue}"; + } + } + + // + // Display command hint + + $this->io->writeln("\$ vendor/bin/deployer {$commandName} \\ "); + + foreach ($parts as $index => $part) { + $last = $index === count($parts) - 1; + $this->io->writeln(" {$part}".($last ? '' : ' \\ ')); + } + } } diff --git a/app/Exceptions/SSHTimeoutException.php b/app/Exceptions/SSHTimeoutException.php new file mode 100644 index 00000000..5354d1bb --- /dev/null +++ b/app/Exceptions/SSHTimeoutException.php @@ -0,0 +1,19 @@ + Array of key ID => description */ - public function getUserSshKeys(): array + public function getPublicKeys(): array { $client = $this->getAPI(); diff --git a/app/Services/DigitalOcean/DigitalOceanKeyService.php b/app/Services/DigitalOcean/DigitalOceanKeyService.php index fcd97a60..9be75110 100644 --- a/app/Services/DigitalOcean/DigitalOceanKeyService.php +++ b/app/Services/DigitalOcean/DigitalOceanKeyService.php @@ -28,7 +28,7 @@ public function __construct( * * @throws \RuntimeException If upload fails */ - public function uploadKey(string $publicKeyPath, string $keyName): int + public function uploadPublicKey(string $publicKeyPath, string $keyName): int { $publicKey = $this->fs->readFile($publicKeyPath); $publicKey = trim($publicKey); @@ -54,7 +54,7 @@ public function uploadKey(string $publicKeyPath, string $keyName): int * * @throws \RuntimeException If deletion fails (non-404 errors) */ - public function deleteKey(int $keyId): void + public function deletePublicKey(int $keyId): void { $client = $this->getAPI(); diff --git a/app/Services/IOService.php b/app/Services/IOService.php index 03c0306f..950d9cdf 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -205,8 +205,6 @@ public function promptText( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - return text( label: $label, placeholder: $placeholder, @@ -235,8 +233,6 @@ public function promptPassword( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - return password( label: $label, placeholder: $placeholder, @@ -264,8 +260,6 @@ public function promptConfirm( string $no = 'No', string $hint = '' ): bool { - $this->suppressPromptSpacing(); - return confirm( label: $label, default: $default, @@ -284,8 +278,6 @@ public function promptConfirm( */ public function promptPause(string $message = 'Press enter to continue...'): bool { - $this->suppressPromptSpacing(); - return pause($message); } @@ -309,8 +301,6 @@ public function promptSelect( mixed $validate = null, string $hint = '' ): int|string { - $this->suppressPromptSpacing(); - return select( label: $label, options: $options, @@ -343,8 +333,6 @@ public function promptMultiselect( mixed $validate = null, string $hint = '' ): array { - $this->suppressPromptSpacing(); - return multiselect( label: $label, options: $options, @@ -380,8 +368,6 @@ public function promptSuggest( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - return suggest( label: $label, options: $options, @@ -414,8 +400,6 @@ public function promptSearch( mixed $validate = null, string $hint = '' ): int|string { - $this->suppressPromptSpacing(); - return search( label: $label, options: $options, @@ -455,6 +439,17 @@ public function promptSpin( // Output Methods // ------------------------------------------------------------------------------- + /** + * Write output without newline. + * + * Used for streaming output where content arrives in chunks. + * For complete lines, use writeln() instead. + */ + public function write(string $text): void + { + $this->io->write($text); + } + /** * Write-out multiple lines. * @@ -565,62 +560,4 @@ public function displayDeets(array $details): void $this->writeln($lines); } - /** - * Display a command replay hint showing how to run non-interactively. - * - * @param array $options Array of option name => value pairs - */ - public function showCommandHint(string $commandName, array $options): void - { - $this->writeln('◆ Run non-interactively:'); - $this->writeln(''); - - // - // Build command options - - $parts = []; - foreach ($options as $optionName => $value) { - if ($value === null || $value === '') { - continue; - } - - // Format the option - $optionFlag = '--'.$optionName; - if (is_bool($value)) { - if ($value) { - $parts[] = $optionFlag; - } - } else { - $stringValue = is_scalar($value) ? (string) $value : ''; - $escapedValue = escapeshellarg($stringValue); - $parts[] = "{$optionFlag}={$escapedValue}"; - } - } - - // - // Display command hint - - $this->writeln(" vendor/bin/deployer {$commandName} \\ "); - - foreach ($parts as $index => $part) { - $last = $index === count($parts) - 1; - $this->writeln(" {$part}".($last ? '' : ' \\ ')); - } - } - - // - // Private Helpers - // ------------------------------------------------------------------------------- - - /** - * Remove the annoying newline that Laravel Prompts adds before each prompt. - * - * Uses ANSI escape sequence to move cursor up one line and clear it. - */ - private function suppressPromptSpacing(): void - { - // Move cursor up one line and clear it - // This compensates for the newline Laravel Prompts adds - echo "\033[1A\033[2K"; - } } diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php index 77b42fc9..6fef8309 100644 --- a/app/Services/SSHService.php +++ b/app/Services/SSHService.php @@ -4,6 +4,8 @@ namespace Bigpixelrocket\DeployerPHP\Services; +use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Exceptions\SSHTimeoutException; use phpseclib3\Crypt\Common\PrivateKey; use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Net\SFTP; @@ -14,28 +16,23 @@ * * Provides connectivity testing, command execution, and file transfer capabilities. * All operations are stateless - connections are created and destroyed per operation. - * Expects absolute paths to SSH keys (path resolution handled by callers). + * Expects absolute paths to SSH keys in ServerDTO (path resolution handled by callers). * * @example - * // Test SSH connectivity (commands resolve key paths before calling) - * $ssh->assertCanConnect('example.com', 22, 'deployer', '/home/user/.ssh/id_ed25519'); + * // Test SSH connectivity + * $server = new ServerDTO(name: 'web1', host: 'example.com', port: 22, username: 'deployer', privateKeyPath: '/home/user/.ssh/id_ed25519'); + * $ssh->assertCanConnect($server); * - * // Execute single commands - * $result = $ssh->executeCommand('example.com', 22, 'deployer', 'uptime', '/home/user/.ssh/id_ed25519'); + * // Execute commands + * $result = $ssh->executeCommand($server, 'uptime'); * echo $result['output']; // "15:30:01 up 42 days, 3:14, 1 user..." * echo $result['exit_code']; // 0 * - * // Execute bash scripts - * $result = $ssh->executeScript('example.com', 22, 'deployer', './scripts/deploy.sh', '/home/user/.ssh/id_ed25519'); - * if ($result['exit_code'] === 0) { - * echo "Deployment successful"; - * } - * * // Upload files to remote server - * $ssh->uploadFile('example.com', 22, 'deployer', './local.txt', '/remote/path/file.txt', '/home/user/.ssh/id_ed25519'); + * $ssh->uploadFile($server, './local.txt', '/remote/path/file.txt'); * * // Download files from remote server - * $ssh->downloadFile('example.com', 22, 'deployer', '/remote/config.yml', './local-config.yml', '/home/user/.ssh/id_ed25519'); + * $ssh->downloadFile($server, '/remote/config.yml', './local-config.yml'); */ class SSHService { @@ -53,67 +50,52 @@ public function __construct( * * @throws \RuntimeException When connection or authentication fails */ - public function assertCanConnect(string $host, int $port, string $username, string $privateKeyPath): void + public function assertCanConnect(ServerDTO $server): void { - $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); + $ssh = $this->createConnection($server); $this->disconnect($ssh); } /** * Execute a command on the remote server and return its output. * + * @param callable|null $outputCallback Optional callback for streaming output (receives string chunks) + * @param int $timeout Timeout in seconds (default: 300 = 5 minutes) * @return array{output: string, exit_code: int} * + * @throws SSHTimeoutException When command execution times out * @throws \RuntimeException When connection, authentication, or command execution fails */ - public function executeCommand(string $host, int $port, string $username, string $command, string $privateKeyPath): array - { - $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); + public function executeCommand( + ServerDTO $server, + string $command, + ?callable $outputCallback = null, + int $timeout = 300 + ): array { + $ssh = $this->createConnection($server); try { - $output = $ssh->exec($command); - $exitCode = (int) $ssh->getExitStatus(); - - return [ - 'output' => is_string($output) ? $output : '', - 'exit_code' => $exitCode, - ]; - } catch (\Throwable $e) { - throw new \RuntimeException("Error executing command on {$host}: " . $e->getMessage(), previous: $e); - } finally { - $this->disconnect($ssh); - } - } - - /** - * Execute a local bash script file on the remote server. - * - * @return array{output: string, exit_code: int} - * - * @throws \RuntimeException When script file cannot be read or execution fails - */ - public function executeScript(string $host, int $port, string $username, string $scriptPath, string $privateKeyPath): array - { - if (!$this->fs->exists($scriptPath)) { - throw new \RuntimeException("Script file does not exist: {$scriptPath}"); - } + // Always set a timeout to prevent infinite hangs + $ssh->setTimeout($timeout); - $scriptContents = $this->fs->readFile($scriptPath); - - $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); - - try { - // Execute script contents through bash using heredoc - $command = "bash <<'DEPLOYER_SCRIPT_EOF'\n{$scriptContents}\nDEPLOYER_SCRIPT_EOF"; - $output = $ssh->exec($command); + $output = $ssh->exec($command, $outputCallback); $exitCode = (int) $ssh->getExitStatus(); + // Check if command timed out (phpseclib returns false on timeout) + if ($output === false) { + throw new SSHTimeoutException( + "Command execution timed out after {$timeout} seconds on {$server->host}" + ); + } + return [ 'output' => is_string($output) ? $output : '', 'exit_code' => $exitCode, ]; + } catch (SSHTimeoutException $e) { + throw $e; } catch (\Throwable $e) { - throw new \RuntimeException("Error executing script {$scriptPath} on {$host}: " . $e->getMessage(), previous: $e); + throw new \RuntimeException("Error executing command on {$server->host}: " . $e->getMessage(), previous: $e); } finally { $this->disconnect($ssh); } @@ -124,23 +106,26 @@ public function executeScript(string $host, int $port, string $username, string * * @throws \RuntimeException When file operations fail */ - public function uploadFile(string $host, int $port, string $username, string $localPath, string $remotePath, string $privateKeyPath): void - { + public function uploadFile( + ServerDTO $server, + string $localPath, + string $remotePath + ): void { if (!$this->fs->exists($localPath)) { throw new \RuntimeException("Local file does not exist: {$localPath}"); } - $sftp = $this->createSFTPConnection($host, $port, $username, $privateKeyPath); + $sftp = $this->createSFTPConnection($server); try { $contents = $this->fs->readFile($localPath); $uploaded = $sftp->put($remotePath, $contents); if (!$uploaded) { - throw new \RuntimeException("Error uploading file to {$remotePath} on {$host}"); + throw new \RuntimeException("Error uploading file to {$remotePath} on {$server->host}"); } } catch (\Throwable $e) { - throw new \RuntimeException("Error uploading file to {$remotePath} on {$host}: " . $e->getMessage(), previous: $e); + throw new \RuntimeException("Error uploading file to {$remotePath} on {$server->host}: " . $e->getMessage(), previous: $e); } finally { $this->disconnect($sftp); } @@ -151,19 +136,22 @@ public function uploadFile(string $host, int $port, string $username, string $lo * * @throws \RuntimeException When file operations fail */ - public function downloadFile(string $host, int $port, string $username, string $remotePath, string $localPath, string $privateKeyPath): void - { - $sftp = $this->createSFTPConnection($host, $port, $username, $privateKeyPath); + public function downloadFile( + ServerDTO $server, + string $remotePath, + string $localPath + ): void { + $sftp = $this->createSFTPConnection($server); try { $contents = $sftp->get($remotePath); if ($contents === false) { - throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}"); + throw new \RuntimeException("Error downloading file from {$remotePath} on {$server->host}"); } $this->fs->dumpFile($localPath, is_string($contents) ? $contents : ''); } catch (\Throwable $e) { - throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}: " . $e->getMessage()); + throw new \RuntimeException("Error downloading file from {$remotePath} on {$server->host}: " . $e->getMessage()); } finally { $this->disconnect($sftp); } @@ -174,54 +162,131 @@ public function downloadFile(string $host, int $port, string $username, string $ // ------------------------------------------------------------------------------- /** - * Create and authenticate an SSH connection. + * Execute an operation with retry logic and exponential backoff. * - * @throws \RuntimeException When connection or authentication fails + * @template T + * @param callable(): T $attemptCallback Callback that attempts operation and returns on success or throws on failure + * @param string $operationDescription Description for error messages (e.g., "connect to host") + * @param int $retryAttempts Number of attempts (default: 5) + * @param int $retryDelaySeconds Initial delay between attempts in seconds (default: 2, doubles each retry) + * + * @return T The successful operation result + * @throws \RuntimeException When all attempts fail */ - private function createConnection(string $host, int $port, string $username, string $privateKeyPath): SSH2 - { - $key = $this->loadPrivateKey($privateKeyPath); + private function withRetry( + callable $attemptCallback, + string $operationDescription, + int $retryAttempts = 5, + int $retryDelaySeconds = 2 + ): mixed { + $attempt = 0; + $delay = $retryDelaySeconds; + $lastException = null; + + while ($attempt < $retryAttempts) { + $attempt++; + + try { + return $attemptCallback(); + } catch (\RuntimeException $e) { + $lastException = $e; + } - try { - $ssh = new SSH2($host, $port); - $loggedIn = $ssh->login($username, $key); - } catch (\Throwable $e) { - throw new \RuntimeException($e->getMessage()); + // Don't sleep after the last failed attempt + if ($attempt < $retryAttempts) { + sleep($delay); + $delay *= 2; // Exponential backoff + } } - if ($loggedIn !== true) { - throw new \RuntimeException("SSH authentication failed for {$username}@{$host}. Check username and key permissions"); + // All attempts failed - loop guarantees $lastException is set (retryAttempts >= 1) + /** @var \RuntimeException $lastException */ + if ($retryAttempts > 1) { + throw new \RuntimeException( + "Failed to {$operationDescription} after {$retryAttempts} attempts", + previous: $lastException + ); } - return $ssh; + throw $lastException; } /** - * Create and authenticate an SFTP connection. + * Create and authenticate an SSH connection with retry logic. * - * @throws \RuntimeException When connection or authentication fails + * @throws \RuntimeException When connection or authentication fails after all retries */ - private function createSFTPConnection(string $host, int $port, string $username, string $privateKeyPath): SFTP + private function createConnection(ServerDTO $server): SSH2 { - $key = $this->loadPrivateKey($privateKeyPath); - - try { - $sftp = new SFTP($host, $port); - } catch (\Throwable $e) { - throw new \RuntimeException("Error initiating SFTP connection to {$host}:{$port}: " . $e->getMessage()); + if ($server->privateKeyPath === null) { + throw new \RuntimeException("Server '{$server->name}' has no private SSH key configured"); } - try { - $loggedIn = $sftp->login($username, $key); - } catch (\Throwable $e) { - throw new \RuntimeException("Error authenticating SFTP for {$username}@{$host}: " . $e->getMessage()); - } + $key = $this->loadPrivateKey($server->privateKeyPath); + + return $this->withRetry( + attemptCallback: function () use ($server, $key) { + try { + $ssh = new SSH2($server->host, $server->port); + $loggedIn = $ssh->login($server->username, $key); + + if ($loggedIn === true) { + return $ssh; + } + + throw new \RuntimeException( + "SSH authentication failed for {$server->username}@{$server->host}. Check username and key permissions" + ); + } catch (\RuntimeException $e) { + throw $e; + } catch (\Throwable $e) { + throw new \RuntimeException( + "Failed to connect to {$server->host}:{$server->port}", + previous: $e + ); + } + }, + operationDescription: "connect to {$server->host}" + ); + } - if ($loggedIn !== true) { - throw new \RuntimeException("SFTP authentication failed for {$username}@{$host}. Check username and key permissions"); + /** + * Create and authenticate an SFTP connection with retry logic. + * + * @throws \RuntimeException When connection or authentication fails after all retries + */ + private function createSFTPConnection(ServerDTO $server): SFTP + { + if ($server->privateKeyPath === null) { + throw new \RuntimeException("Server '{$server->name}' has no private SSH key configured"); } - return $sftp; + $key = $this->loadPrivateKey($server->privateKeyPath); + + return $this->withRetry( + attemptCallback: function () use ($server, $key) { + try { + $sftp = new SFTP($server->host, $server->port); + $loggedIn = $sftp->login($server->username, $key); + + if ($loggedIn === true) { + return $sftp; + } + + throw new \RuntimeException( + "SFTP authentication failed for {$server->username}@{$server->host}. Check username and key permissions" + ); + } catch (\RuntimeException $e) { + throw $e; + } catch (\Throwable $e) { + throw new \RuntimeException( + "Failed to connect via SFTP to {$server->host}:{$server->port}", + previous: $e + ); + } + }, + operationDescription: "connect via SFTP to {$server->host}" + ); } /** @@ -248,7 +313,7 @@ private function disconnect(SSH2|SFTP $connection): void private function loadPrivateKey(string $privateKeyPath): PrivateKey { if (!$this->fs->exists($privateKeyPath)) { - throw new \RuntimeException("SSH key does not exist: {$privateKeyPath}"); + throw new \RuntimeException("Private SSH key does not exist: {$privateKeyPath}"); } $keyContents = $this->fs->readFile($privateKeyPath); diff --git a/app/Traits/DigitalOceanCommandTrait.php b/app/Traits/DigitalOceanCommandTrait.php deleted file mode 100644 index 674bb9e8..00000000 --- a/app/Traits/DigitalOceanCommandTrait.php +++ /dev/null @@ -1,67 +0,0 @@ -env->get(['DIGITALOCEAN_API_TOKEN', 'DO_API_TOKEN']); - - if ($apiToken === null || $apiToken === '') { - throw new \InvalidArgumentException('DigitalOcean API token not found in environment'); - } - - // Initialize DigitalOcean API - $this->io->promptSpin( - fn () => $this->digitalOcean->initialize($apiToken), - 'Initializing DigitalOcean API...' - ); - - return Command::SUCCESS; - } catch (\InvalidArgumentException) { - // Token configuration issue - $this->io->error('DigitalOcean API token not found in environment.'); - $this->io->writeln(''); - $this->io->writeln('Set DIGITALOCEAN_API_TOKEN or DO_API_TOKEN in your .env file.'); - $this->io->writeln(''); - - return Command::FAILURE; - } catch (\RuntimeException $e) { - // API authentication failure - $this->io->error($e->getMessage()); - $this->io->writeln(''); - $this->io->writeln('Check that your API token is valid and has not expired.'); - $this->io->writeln(''); - - return Command::FAILURE; - } - } -} diff --git a/app/Traits/DigitalOceanTrait.php b/app/Traits/DigitalOceanTrait.php new file mode 100644 index 00000000..77ec813f --- /dev/null +++ b/app/Traits/DigitalOceanTrait.php @@ -0,0 +1,293 @@ +env->get(['DIGITALOCEAN_API_TOKEN', 'DO_API_TOKEN']); + + // Initialize DigitalOcean API + $this->io->promptSpin( + fn () => $this->digitalOcean->initialize($apiToken), + 'Initializing DigitalOcean API...' + ); + + return Command::SUCCESS; + } catch (\InvalidArgumentException) { + // Token configuration issue + $this->nay('DigitalOcean API token not found in environment.'); + $this->io->writeln('Set DIGITALOCEAN_API_TOKEN or DO_API_TOKEN in your .env file.'); + $this->io->writeln(''); + + return Command::FAILURE; + } catch (\RuntimeException $e) { + // API authentication failure + $this->nay($e->getMessage()); + $this->io->writeln('Check that your API token is valid and has not expired.'); + $this->io->writeln(''); + + return Command::FAILURE; + } + } + + // + // UI + // ------------------------------------------------------------------------------- + + /** + * Display a warning if no keys are available. Otherwise, return all keys. + * + * @param array|null $keys Optional pre-fetched keys; if null, fetches from DigitalOcean API + * @return array|int Returns array of keys (ID => description) or Command::FAILURE + */ + protected function ensureKeysAvailable(?array $keys = null): array|int + { + // + // Get all keys + + if ($keys === null) { + try { + $keys = $this->digitalOcean->account->getPublicKeys(); + } catch (\RuntimeException $e) { + $this->nay('Failed to retrieve public SSH keys: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + // + // Check if no keys are available + + if (count($keys) === 0) { + $this->io->warning('No public SSH keys found in your DigitalOcean account'); + $this->io->writeln([ + '', + 'Run key:add:digitalocean to add a public SSH key', + '', + ]); + + return Command::FAILURE; + } + + return $keys; + } + + /** + * Select a key from available keys via option or interactive prompt. + * + * @param array|null $availableKeys Optional pre-fetched keys; if null, fetches from DigitalOcean API + * @return array{id: string|int, description: string}|int Array with selected key ID and description on success, or Command::FAILURE on error + */ + protected function selectKey(?array $availableKeys = null): array|int + { + // + // Get all keys + + if ($availableKeys === null) { + $availableKeys = $this->ensureKeysAvailable(); + + if (is_int($availableKeys)) { + return Command::FAILURE; + } + } + + // + // Get key via option or prompt + + /** @var string $selectedKey */ + $selectedKey = $this->io->getOptionOrPrompt( + 'key', + fn (): string => (string) $this->io->promptSelect( + label: 'Select public SSH key:', + options: $availableKeys + ) + ); + + // + // Validate key exists in available keys + + if (!isset($availableKeys[$selectedKey])) { + $this->nay("Public SSH key '{$selectedKey}' not found"); + + return Command::FAILURE; + } + + return [ + 'id' => $selectedKey, + 'description' => $availableKeys[$selectedKey], + ]; + } + + // ------------------------------------------------------------------------------- + // + // Validation + // + // ------------------------------------------------------------------------------- + + /** + * Validate region against available regions. + * + * @param array $validRegions Available regions from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDigitalOceanRegion(mixed $region, array $validRegions): ?string + { + if (!is_string($region)) { + return 'Region must be a string'; + } + + if (trim($region) === '') { + return 'Region cannot be empty'; + } + + // Check if region exists in account's available regions + if (!isset($validRegions[$region])) { + return "Invalid region: '{$region}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate size against available droplet sizes. + * + * @param array $validSizes Available sizes from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDigitalOceanDropletSize(mixed $size, array $validSizes): ?string + { + if (!is_string($size)) { + return 'Droplet size must be a string'; + } + + if (trim($size) === '') { + return 'Droplet size cannot be empty'; + } + + // Check if size exists in account's available sizes + if (!isset($validSizes[$size])) { + return "Invalid droplet size: '{$size}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate image against available images. + * + * @param array $validImages Available images from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDigitalOceanDropletImage(mixed $image, array $validImages): ?string + { + if (!is_string($image)) { + return 'Droplet image must be a string'; + } + + if (trim($image) === '') { + return 'Droplet image cannot be empty'; + } + + // Check if image exists in account's available images + if (!isset($validImages[$image])) { + return "Invalid droplet image: '{$image}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate VPC UUID format. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDigitalOceanVPCUUID(mixed $uuid): ?string + { + if (!is_string($uuid)) { + return 'VPC UUID must be a string'; + } + + // Empty is allowed (optional) - will use default VPC + if (trim($uuid) === '' || $uuid === 'default') { + return null; + } + + // Validate RFC 4122 UUID format + $uuidPattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + if (!preg_match($uuidPattern, $uuid)) { + return 'VPC UUID must be a valid UUID (e.g., 12345678-1234-1234-1234-123456789abc) or "default"'; + } + + return null; + } + + /** + * Validate single SSH key ID against available keys. + * + * @param array $validKeys Available SSH keys from account (key ID => description) + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateDigitalOceanSSHKey(mixed $keyId, array $validKeys): ?string + { + if (!is_string($keyId) && !is_int($keyId)) { + return 'SSH key ID must be a string or integer'; + } + + // Validate numeric string + if (is_string($keyId) && !ctype_digit($keyId)) { + return 'SSH key ID must be numeric'; + } + + // Convert to integer for validation + $keyIdInt = is_int($keyId) ? $keyId : (int) $keyId; + + // Check if key exists in account's available keys + if (!isset($validKeys[$keyIdInt])) { + return "Invalid SSH key: ID {$keyIdInt} is not available in your DigitalOcean account"; + } + + return null; + } + +} diff --git a/app/Traits/DigitalOceanValidationTrait.php b/app/Traits/DigitalOceanValidationTrait.php deleted file mode 100644 index fe151e42..00000000 --- a/app/Traits/DigitalOceanValidationTrait.php +++ /dev/null @@ -1,170 +0,0 @@ - $validRegions Available regions from account - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateRegionInput(mixed $region, array $validRegions): ?string - { - if (!is_string($region)) { - return 'Region must be a string'; - } - - if (trim($region) === '') { - return 'Region cannot be empty'; - } - - // Check if region exists in account's available regions - if (!isset($validRegions[$region])) { - return "Invalid region: '{$region}' is not available in your DigitalOcean account"; - } - - return null; - } - - /** - * Validate size against available droplet sizes. - * - * @param array $validSizes Available sizes from account - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateSizeInput(mixed $size, array $validSizes): ?string - { - if (!is_string($size)) { - return 'Size must be a string'; - } - - if (trim($size) === '') { - return 'Size cannot be empty'; - } - - // Check if size exists in account's available sizes - if (!isset($validSizes[$size])) { - return "Invalid size: '{$size}' is not available in your DigitalOcean account"; - } - - return null; - } - - /** - * Validate image against available images. - * - * @param array $validImages Available images from account - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateImageInput(mixed $image, array $validImages): ?string - { - if (!is_string($image)) { - return 'Image must be a string'; - } - - if (trim($image) === '') { - return 'Image cannot be empty'; - } - - // Check if image exists in account's available images - if (!isset($validImages[$image])) { - return "Invalid image: '{$image}' is not available in your DigitalOcean account"; - } - - return null; - } - - /** - * Validate VPC UUID format. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateVpcUuidInput(mixed $uuid): ?string - { - if (!is_string($uuid)) { - return 'VPC UUID must be a string'; - } - - // Empty is allowed (optional) - will use default VPC - if (trim($uuid) === '' || $uuid === 'default') { - return null; - } - - // Validate RFC 4122 UUID format - $uuidPattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; - if (!preg_match($uuidPattern, $uuid)) { - return 'VPC UUID must be a valid UUID (e.g., 12345678-1234-1234-1234-123456789abc) or "default"'; - } - - return null; - } - - /** - * Validate single SSH key ID against available keys. - * - * @param array $validKeys Available SSH keys from account (key ID => description) - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateSshKeyInput(mixed $keyId, array $validKeys): ?string - { - if (!is_string($keyId) && !is_int($keyId)) { - return 'SSH key ID must be a string or integer'; - } - - // Convert to integer for validation - $keyIdInt = is_int($keyId) ? $keyId : (int) $keyId; - - // Check if key exists in account's available keys - if (!isset($validKeys[$keyIdInt])) { - return "Invalid SSH key: ID {$keyIdInt} is not available in your DigitalOcean account"; - } - - return null; - } - - /** - * Validate comma-separated SSH key IDs. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateSshKeysInput(mixed $keys): ?string - { - if (!is_string($keys)) { - return 'SSH keys must be a string'; - } - - // Empty is allowed (optional) - if (trim($keys) === '') { - return null; - } - - // Split and validate each key ID - $keyArray = array_map(trim(...), explode(',', $keys)); - foreach ($keyArray as $key) { - if ($key === '') { - return 'SSH key IDs cannot be empty (remove extra commas)'; - } - - // Keys should be numeric IDs or fingerprints - $isNumeric = ctype_digit($key); - $isFingerprint = preg_match('/^[a-f0-9:]{47,95}$/i', $key) === 1; - - if (!$isNumeric && !$isFingerprint) { - return "Invalid SSH key format: '{$key}' (must be numeric ID or fingerprint)"; - } - } - - return null; - } -} diff --git a/app/Traits/KeyHelpersTrait.php b/app/Traits/KeyHelpersTrait.php deleted file mode 100644 index 4e497148..00000000 --- a/app/Traits/KeyHelpersTrait.php +++ /dev/null @@ -1,137 +0,0 @@ -io->writeln([ - " ID: {$id}", - " Name: {$name}", - " Fingerprint: {$fingerprint}", - '', - ]); - } - - /** - * Resolve a usable private key path. - * - * Priority order: - * 1. Provided path (with ~ expansion) - * 2. ~/.ssh/id_ed25519 - * 3. ~/.ssh/id_rsa - */ - protected function resolvePrivateKeyPath(?string $path): ?string - { - return $this->resolveKeyWithFallback($path, [ - '~/.ssh/id_ed25519', - '~/.ssh/id_rsa', - ]); - } - - /** - * Resolve a usable public key path. - * - * Priority order: - * 1. Provided path (with ~ expansion) - * 2. ~/.ssh/id_ed25519.pub - * 3. ~/.ssh/id_rsa.pub - */ - protected function resolvePublicKeyPath(?string $path): ?string - { - return $this->resolveKeyWithFallback($path, [ - '~/.ssh/id_ed25519.pub', - '~/.ssh/id_rsa.pub', - ]); - } - - /** - * Resolve a key path with fallback to default locations. - * - * Priority order: - * 1. Provided path (with ~ expansion) - * 2. Fallback paths - * - * @param string|null $path The path to resolve - * @param array $fallback The fallback paths - * @return string|null The resolved path, or null if not found - */ - protected function resolveKeyWithFallback(?string $path, array $fallback): ?string - { - $candidates = []; - - if (is_string($path) && $path !== '') { - $candidates[] = $path; - } - - $candidates = array_merge($candidates, $fallback); - - return $this->fs->getFirstExisting($candidates); - } - - /** - * Select a key from available keys via option or interactive prompt. - * - * @param array $availableKeys Map of key IDs to descriptions - * @param string $optionName Option name to check for pre-provided value - * @param string $promptLabel Label for interactive prompt - * @return array{key: string|null, exit_code: int} Selected key ID and exit code - */ - protected function selectKey(array $availableKeys, string $optionName = 'key', string $promptLabel = 'Select SSH key:'): array - { - // - // Check if keys are available - - if (count($availableKeys) === 0) { - $this->io->warning('No SSH keys found'); - $this->io->writeln(''); - - return ['key' => null, 'exit_code' => Command::SUCCESS]; - } - - // - // Get key via option or prompt - - /** @var string $selectedKey */ - $selectedKey = $this->io->getOptionOrPrompt( - $optionName, - fn (): string => (string) $this->io->promptSelect( - label: $promptLabel, - options: $availableKeys - ) - ); - - // - // Validate key exists in available keys - - if (!isset($availableKeys[$selectedKey])) { - $this->io->error("SSH key '{$selectedKey}' not found"); - - return ['key' => null, 'exit_code' => Command::FAILURE]; - } - - return ['key' => $selectedKey, 'exit_code' => Command::SUCCESS]; - } -} diff --git a/app/Traits/KeyValidationTrait.php b/app/Traits/KeysTrait.php similarity index 55% rename from app/Traits/KeyValidationTrait.php rename to app/Traits/KeysTrait.php index f69d337c..54e73b68 100644 --- a/app/Traits/KeyValidationTrait.php +++ b/app/Traits/KeysTrait.php @@ -7,14 +7,86 @@ use Bigpixelrocket\DeployerPHP\Services\FilesystemService; /** - * Common SSH key validation helpers for commands. + * Reusable SSH key things. * - * Requires classes using this trait to have a FilesystemService property. + * Requires classes using this trait to have FilesystemService property. * * @property FilesystemService $fs */ -trait KeyValidationTrait +trait KeysTrait { + // ------------------------------------------------------------------------------- + // + // Helpers + // + // ------------------------------------------------------------------------------- + + // + // Key resolution + // ------------------------------------------------------------------------------- + + /** + * Resolve a usable private key path. + * + * Priority order: + * 1. Provided path (with ~ expansion) + * 2. ~/.ssh/id_ed25519 + * 3. ~/.ssh/id_rsa + */ + protected function resolvePrivateKeyPath(?string $path): ?string + { + return $this->resolveKeyWithFallback($path, [ + '~/.ssh/id_ed25519', + '~/.ssh/id_rsa', + ]); + } + + /** + * Resolve a usable public key path. + * + * Priority order: + * 1. Provided path (with ~ expansion) + * 2. ~/.ssh/id_ed25519.pub + * 3. ~/.ssh/id_rsa.pub + */ + protected function resolvePublicKeyPath(?string $path): ?string + { + return $this->resolveKeyWithFallback($path, [ + '~/.ssh/id_ed25519.pub', + '~/.ssh/id_rsa.pub', + ]); + } + + /** + * Resolve a key path with fallback to default locations. + * + * Priority order: + * 1. Provided path (with ~ expansion) + * 2. Fallback paths + * + * @param string|null $path The path to resolve + * @param array $fallback The fallback paths + * @return string|null The resolved path, or null if not found + */ + protected function resolveKeyWithFallback(?string $path, array $fallback): ?string + { + $candidates = []; + + if (is_string($path) && $path !== '') { + $candidates[] = $path; + } + + $candidates = array_merge($candidates, $fallback); + + return $this->fs->getFirstExisting($candidates); + } + + // ------------------------------------------------------------------------------- + // + // Validation + // + // ------------------------------------------------------------------------------- + /** * Validate SSH public key file: * @@ -48,8 +120,8 @@ protected function validateKeyPathInput(mixed $path): ?string // Read and validate key format try { - $publicKey = $this->fs->readFile($expandedPath); - $publicKey = trim((string) $publicKey); + $key = $this->fs->readFile($expandedPath); + $key = trim((string) $key); // Validate key format (should start with supported SSH key types) $validPrefixes = [ @@ -67,7 +139,7 @@ protected function validateKeyPathInput(mixed $path): ?string $isValid = false; foreach ($validPrefixes as $prefix) { - if (str_starts_with($publicKey, $prefix)) { + if (str_starts_with($key, $prefix)) { $isValid = true; break; } @@ -75,7 +147,7 @@ protected function validateKeyPathInput(mixed $path): ?string if (!$isValid) { // Explicit error for obsolete DSA keys - if (str_starts_with($publicKey, 'ssh-dss')) { + if (str_starts_with($key, 'ssh-dss')) { return 'DSA (ssh-dss) keys are obsolete and insecure'; } diff --git a/app/Traits/PlaybookHelpersTrait.php b/app/Traits/PlaybooksTrait.php similarity index 54% rename from app/Traits/PlaybookHelpersTrait.php rename to app/Traits/PlaybooksTrait.php index e9db3e4f..1ae79802 100644 --- a/app/Traits/PlaybookHelpersTrait.php +++ b/app/Traits/PlaybooksTrait.php @@ -6,6 +6,7 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Exceptions\SSHTimeoutException; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\SSHService; @@ -13,7 +14,7 @@ use Symfony\Component\Yaml\Yaml; /** - * Reusable helpers for executing playbooks on remote servers. + * Reusable playbook things. * * Requires classes using this trait to have Container, IOService, SSHService, and FilesystemService properties. * @@ -22,10 +23,8 @@ * @property IOService $io * @property SSHService $ssh */ -trait PlaybookHelpersTrait +trait PlaybooksTrait { - use KeyHelpersTrait; - /** * Execute a playbook on a server. * @@ -35,24 +34,32 @@ trait PlaybookHelpersTrait * * @param string $playbookName Playbook name without .sh extension (e.g., 'server-info', 'install-php', etc) * @param array $playbookVars Playbook variables to pass to the playbook (don't pass sensitive data) + * @param bool $streamOutput Stream output in real-time (true) or show spinner and display all at end (false) * @return array|int Returns parsed YAML on success or Command::FAILURE on error */ protected function executePlaybook( ServerDTO $server, string $playbookName, string $spinnerMessage, - array $playbookVars = [] + array $playbookVars = [], + bool $streamOutput = false ): array|int { - $projectRoot = dirname(__DIR__, 2); + $projectRoot = dirname(__DIR__, 3); $playbookPath = $projectRoot . '/playbooks/' . $playbookName . '.sh'; $scriptContents = $this->fs->readFile($playbookPath); - // Generate unique output filename + // Unique output file name $outputFile = sprintf('/tmp/deployer-output-%d-%s.yml', time(), bin2hex(random_bytes(8))); - // Build variable prefix with DEPLOYER_OUTPUT_FILE - $varsPrefix = sprintf('DEPLOYER_OUTPUT_FILE=%s ', escapeshellarg($outputFile)); - foreach ($playbookVars as $key => $value) { + // Override default vars with playbook vars + $vars = [ + 'DEPLOYER_OUTPUT_FILE' => $outputFile, + ...$playbookVars, + ]; + + // Build var prefix string + $varsPrefix = ''; + foreach ($vars as $key => $value) { $varsPrefix .= sprintf('%s=%s ', $key, escapeshellarg((string) $value)); } @@ -63,53 +70,71 @@ protected function executePlaybook( $scriptContents ); - // Resolve SSH key path - $privateKeyPath = $this->resolvePrivateKeyPath($server->privateKeyPath); - - if ($privateKeyPath === null) { - throw new \RuntimeException('No valid SSH private key found'); - } - // Execute command try { - $result = $this->io->promptSpin( - callback: fn () => $this->ssh->executeCommand( - $server->host, - $server->port, - $server->username, + if ($streamOutput) { + // Streaming output in real-time + $result = $this->ssh->executeCommand( + $server, $scriptWithVars, - $privateKeyPath - ), - message: $spinnerMessage - ); - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); + fn (string $chunk) => $this->io->write($chunk) + ); + } else { + // No streaming, use spinner and display output at end + $result = $this->io->promptSpin( + callback: fn () => $this->ssh->executeCommand( + $server, + $scriptWithVars + ), + message: $spinnerMessage + ); + + $output = trim((string) $result['output']); + if (!empty($output)) { + $this->io->writeln(explode("\n", $output)); + } + } + + $this->io->writeln(''); // Empty line after output + } catch (SSHTimeoutException $e) { + $this->nay($e->getMessage()); + $this->io->writeln([ + '', + 'The process took longer than expected to complete.', + '', + 'Package downloads or installation are taking longer than expected. Either:', + ' • Server has a slow network connection', + ' • Or the server is under heavy load', + '', + 'You can try:', + ' • Running the command again (operations are idempotent)', + ' • Checking server load with server:info', + ' • SSH into the server to check running processes', + '', + ]); return Command::FAILURE; - } + } catch (\RuntimeException $e) { + $this->nay($e->getMessage()); - // Display all output as progress messages - $output = trim((string) $result['output']); - if (!empty($output)) { - $this->io->writeln(explode("\n", $output)); + return Command::FAILURE; } // Check exit code if ($result['exit_code'] !== 0) { - $this->io->error('Playbook execution failed'); + $this->nay('Playbook execution failed'); return Command::FAILURE; } - // Read YAML output from file and clean up + // Read YAML output from file and clean up (quick operation, short timeout) try { $yamlResult = $this->io->promptSpin( callback: fn () => $this->ssh->executeCommand( - $server->host, - $server->port, - $server->username, + $server, sprintf('cat %s 2>/dev/null && rm -f %s', escapeshellarg($outputFile), escapeshellarg($outputFile)), - $privateKeyPath + null, + 30 ), message: $spinnerMessage ); @@ -120,7 +145,7 @@ protected function executePlaybook( throw new \RuntimeException('Something went wrong while trying to read ' . $outputFile); } } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); + $this->nay($e->getMessage()); return Command::FAILURE; } @@ -130,15 +155,14 @@ protected function executePlaybook( $parsed = Yaml::parse($yamlContent); if (!is_array($parsed)) { - throw new \RuntimeException('Unexpected format'); + throw new \RuntimeException('Unexpected format, expected YAML array in ' . $outputFile); } /** @var array $parsed */ return $parsed; } catch (\Throwable $e) { - $this->io->error($e->getMessage()); + $this->nay($e->getMessage()); $this->io->writeln([ - '', ''.$yamlContent.'', '', ]); diff --git a/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php deleted file mode 100644 index c185b486..00000000 --- a/app/Traits/ServerHelpersTrait.php +++ /dev/null @@ -1,124 +0,0 @@ -|int Returns array of servers or Command::SUCCESS if no servers available - */ - protected function ensureServersAvailable(): array|int - { - // Get all servers - $allServers = $this->servers->all(); - - // Check if no servers are available - if (count($allServers) === 0) { - $this->io->warning('No servers available'); - $this->io->writeln([ - '', - 'Run server:provision to provision your first server,', - 'or run server:add to add an existing server.', - '', - ]); - - return Command::SUCCESS; - } - - return $allServers; - } - - /** - * Select a server from inventory by name option or interactive prompt. - * - * @return ServerDTO|int Returns ServerDTO on success, or Command::SUCCESS if empty inventory, or Command::FAILURE if not found - */ - protected function selectServer(string $optionName = 'server', string $promptLabel = 'Select server:'): ServerDTO|int - { - // - // Get all servers - - $allServers = $this->ensureServersAvailable(); - - if (is_int($allServers)) { - return $allServers; - } - - // - // Extract server names and prompt for selection - - $serverNames = array_map(fn (ServerDTO $server) => $server->name, $allServers); - - $name = (string) $this->io->getOptionOrPrompt( - $optionName, - fn () => $this->io->promptSelect( - label: $promptLabel, - options: $serverNames, - ) - ); - - // - // Find server by name - - $server = $this->servers->findByName($name); - - if ($server === null) { - $this->io->error("Server '{$name}' not found in inventory"); - - return Command::FAILURE; - } - - return $server; - } - - /** - * Display server details including optional sites. - * - * @param array $sites - */ - protected function displayServerDeets(ServerDTO $server, array $sites = []): void - { - $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(''); - } - - /** - * Check if a server is provisioned on DigitalOcean. - */ - protected function isDigitalOceanServer(ServerDTO $server): bool - { - return $server->provider === 'digitalocean' && $server->dropletId !== null; - } -} diff --git a/app/Traits/ServerInfoTrait.php b/app/Traits/ServerInfoTrait.php deleted file mode 100644 index bb52bed0..00000000 --- a/app/Traits/ServerInfoTrait.php +++ /dev/null @@ -1,81 +0,0 @@ -|int Returns parsed server info or failure code on failure - */ - protected function getServerInfo(ServerDTO $server): array|int - { - return $this->executePlaybook( - $server, - 'server-info', - 'Gathering server information...' - ); - } - - /** - * Display formatted server information. - * - * @param array $info - */ - protected function displayServerInfo(array $info): void - { - $distroName = match ($info['distro'] ?? 'unknown') { - 'debian' => 'Debian/Ubuntu', - 'redhat' => 'RedHat/CentOS/Fedora', - 'amazon' => 'Amazon Linux', - default => 'Unknown', - }; - - $permissionsText = match ($info['permissions'] ?? 'none') { - 'root' => 'root', - 'sudo' => 'sudo', - default => 'insufficient', - }; - - $deets = [ - 'Distro' => $distroName, - 'User' => $permissionsText, - ]; - - $this->io->displayDeets($deets); - $this->io->writeln(''); - - $services = []; - - // Add listening ports if any - if (isset($info['ports']) && is_array($info['ports']) && count($info['ports']) > 0) { - $portsList = []; - foreach ($info['ports'] as $port => $process) { - if (is_numeric($port) && is_string($process)) { - $portsList[] = "Port {$port}: {$process}"; - } - } - if (count($portsList) > 0) { - $services = $portsList; - } - } - - $this->io->displayDeets(['Services' => $services]); - $this->io->writeln(''); - } -} diff --git a/app/Traits/ServerValidationTrait.php b/app/Traits/ServerValidationTrait.php deleted file mode 100644 index 9a0a7539..00000000 --- a/app/Traits/ServerValidationTrait.php +++ /dev/null @@ -1,93 +0,0 @@ -servers->findByName($name); - if ($existing !== null) { - return "Server '{$name}' already exists in inventory"; - } - - return null; - } - - /** - * Validate host is a valid IP or domain and unique. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateHostInput(mixed $host): ?string - { - if (!is_string($host)) { - return 'Host must be a string'; - } - - // Check format - $isValidIp = filter_var($host, FILTER_VALIDATE_IP) !== false; - $isValidDomain = filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; - - if (!$isValidIp && !$isValidDomain) { - return 'Must be a valid IP address or domain name (e.g., 192.168.1.100, example.com)'; - } - - // Check uniqueness - $existing = $this->servers->findByHost($host); - if ($existing !== null) { - return "Host '{$host}' is already used by server '{$existing->name}'"; - } - - return null; - } - - /** - * Validate port is in valid range. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validatePortInput(mixed $portString): ?string - { - if (!is_string($portString)) { - return 'Port must be a string'; - } - - if (!ctype_digit($portString)) { - return 'Port must be a number'; - } - - $port = (int) $portString; - if ($port < 1 || $port > 65535) { - return 'Port must be between 1 and 65535 (common SSH ports: 22, 2222, 22000)'; - } - - return null; - } -} diff --git a/app/Traits/ServersTrait.php b/app/Traits/ServersTrait.php new file mode 100644 index 00000000..093d4a46 --- /dev/null +++ b/app/Traits/ServersTrait.php @@ -0,0 +1,278 @@ +|null $servers Optional pre-fetched servers; if null, fetches from repository + * @return array|int Returns array of servers or Command::FAILURE if no servers available + */ + protected function ensureServersAvailable(?array $servers = null): array|int + { + // + // Get all servers + + $allServers = $servers ?? $this->servers->all(); + + // + // Check if no servers are available + + if (count($allServers) === 0) { + $this->io->warning('No servers available'); + $this->io->writeln([ + '', + 'Run server:provision to provision your first server,', + 'or run server:add to add an existing server.', + '', + ]); + + return Command::FAILURE; + } + + return $allServers; + } + + /** + * Select a server from inventory by name option or interactive prompt. + * + * @param array|null $servers Optional pre-fetched servers; if null, fetches from repository + * @return ServerDTO|int Returns ServerDTO on success, or Command::FAILURE on error + */ + protected function selectServer(?array $servers = null): ServerDTO|int + { + // + // Get all servers + + if ($servers === null) { + $servers = $this->ensureServersAvailable(); + + if (is_int($servers)) { + return Command::FAILURE; + } + } + + // + // Extract server names and prompt for selection + + $serverNames = array_map(fn (ServerDTO $server) => $server->name, $servers); + + $name = (string) $this->io->getOptionOrPrompt( + 'server', + fn () => $this->io->promptSelect( + label: 'Select server:', + options: $serverNames, + ) + ); + + // + // Find server by name + + $server = $this->servers->findByName($name); + + if ($server === null) { + $this->nay("Server '{$name}' not found in inventory"); + + return Command::FAILURE; + } + + return $server; + } + + /** + * Display server details including associated sites. + */ + protected function displayServerDeets(ServerDTO $server): void + { + $deets = [ + 'Name' => $server->name, + 'Host' => $server->host, + 'Port' => $server->port, + 'User' => $server->username, + 'Key' => $server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)', + ]; + + $sites = $this->sites->findByServer($server->name); + + 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(''); + } + + /** + * Verify SSH connection to a server with proper error handling. + * + * Differentiates between fatal errors (authentication/key issues) and non-fatal + * connection timeouts (expected for newly provisioned servers). + * + * @return int Returns Command::SUCCESS if verification succeeds or connection timeout (non-fatal), Command::FAILURE on fatal errors + */ + protected function verifySSHConnection(ServerDTO $server): int + { + try { + $this->io->promptSpin( + callback: function () use ($server) { + $this->ssh->assertCanConnect($server); + }, + message: 'Verifying SSH connection...' + ); + + $this->yay('SSH connection established'); + + return Command::SUCCESS; + } catch (\RuntimeException $e) { + // Differentiate between connection issues (expected) and configuration errors (fatal) + $message = $e->getMessage(); + if (str_contains($message, 'authentication') || + str_contains($message, 'key does not exist') || + str_contains($message, 'key permissions')) { + $this->nay($e->getMessage()); + + return Command::FAILURE; + } + + // Connection timeout - expected for newly provisioned servers + $this->io->warning('SSH is not responding'); + $this->io->writeln([ + '', + 'The server will be added to the inventory regardless. You can either:', + ' • Wait a minute and run server:info --server=' . $server->name . ' to check again', + ' • Or run server:install --server=' . $server->name . ' to install software when ready', + '', + ]); + + return Command::SUCCESS; + } + } + + // + // Provider helpers + // ------------------------------------------------------------------------------- + + /** + * Check if a server is provisioned on DigitalOcean. + */ + protected function isDigitalOceanServer(ServerDTO $server): bool + { + return $server->provider === 'digitalocean' && $server->dropletId !== null; + } + + // ------------------------------------------------------------------------------- + // + // Validation + // + // ------------------------------------------------------------------------------- + + /** + * Validate server name format and uniqueness. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateServerName(mixed $name): ?string + { + if (!is_string($name)) { + return 'Server name must be a string'; + } + + // Check if empty + if (trim($name) === '') { + return 'Server name cannot be empty'; + } + + // Check uniqueness + $existing = $this->servers->findByName($name); + if ($existing !== null) { + return "Server '{$name}' already exists in inventory"; + } + + return null; + } + + /** + * Validate host is a valid IP or domain and unique. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateServerHost(mixed $host): ?string + { + if (!is_string($host)) { + return 'Host must be a string'; + } + + // Check format + $isValidIp = filter_var($host, FILTER_VALIDATE_IP) !== false; + $isValidDomain = filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + + if (!$isValidIp && !$isValidDomain) { + return 'Must be a valid IP address or domain name (e.g., 192.168.1.100, example.com)'; + } + + // Check uniqueness + $existing = $this->servers->findByHost($host); + if ($existing !== null) { + return "Host '{$host}' is already used by server '{$existing->name}'"; + } + + return null; + } + + /** + * Validate port is in valid range. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateServerPort(mixed $portString): ?string + { + if (!is_string($portString)) { + return 'Port must be a string'; + } + + if (!ctype_digit($portString)) { + return 'Port must be a number'; + } + + $port = (int) $portString; + if ($port < 1 || $port > 65535) { + return 'Port must be between 1 and 65535 (common SSH ports: 22, 2222, 22000)'; + } + + return null; + } + +} diff --git a/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php deleted file mode 100644 index cf48dfb6..00000000 --- a/app/Traits/SiteHelpersTrait.php +++ /dev/null @@ -1,119 +0,0 @@ -|int Returns array of sites or Command::SUCCESS if no sites available - */ - 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([ - '', - 'Use site:add to add a site', - '', - ]); - - 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; - } - - // - // Extract site domains and prompt for selection - - $siteDomains = array_map(fn (SiteDTO $site) => $site->domain, $allSites); - - $domain = (string) $this->io->getOptionOrPrompt( - $optionName, - fn () => $this->io->promptSelect( - label: $promptLabel, - options: $siteDomains, - ) - ); - - // - // Find site by domain - - $site = $this->sites->findByDomain($domain); - - if ($site === null) { - $this->io->error("Site '{$domain}' not found in inventory"); - - return Command::FAILURE; - } - - return $site; - } - - /** - * Display site details. - */ - protected function displaySiteDeets(SiteDTO $site): void - { - $details = ['Domain' => $site->domain]; - - if ($site->isLocal()) { - $details['Source'] = 'Local'; - } else { - $details = [ - ...$details, - 'Source' => 'Git', - 'Repo' => $site->repo, - 'Branch' => $site->branch, - ]; - } - - if (count($site->servers) > 1) { - $details['Servers'] = $site->servers; - } elseif (count($site->servers) === 1) { - $details['Server'] = $site->servers[0]; - } - - $this->io->displayDeets($details); - $this->io->writeln(''); - } -} diff --git a/app/Traits/SiteValidationTrait.php b/app/Traits/SiteValidationTrait.php deleted file mode 100644 index 98318fd5..00000000 --- a/app/Traits/SiteValidationTrait.php +++ /dev/null @@ -1,152 +0,0 @@ -sites->findByDomain($domain); - if ($existing !== null) { - return "Domain '{$domain}' already exists in inventory"; - } - - return null; - } - - /** - * Validate branch name is not empty. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateBranchInput(mixed $branch): ?string - { - if (!is_string($branch)) { - return 'Branch name must be a string'; - } - - if (trim($branch) === '') { - return 'Branch name cannot be empty'; - } - - return null; - } - - /** - * Validate git repository URL format. - * - * @return string|null Error message if invalid, null if valid - */ - protected function validateRepoInput(mixed $repo): ?string - { - if (!is_string($repo)) { - return 'Repository URL must be a string'; - } - - if (trim($repo) === '') { - return 'Repository URL cannot be empty'; - } - - // Basic format check - should start with git@, https://, http://, or ssh:// - $repo = trim($repo); - $validPrefixes = ['git@', 'https://', 'http://', 'ssh://']; - $hasValidPrefix = false; - - foreach ($validPrefixes as $prefix) { - if (str_starts_with($repo, $prefix)) { - $hasValidPrefix = true; - break; - } - } - - if (!$hasValidPrefix) { - return 'Repository URL must start with git@, https://, http://, or ssh://'; - } - - return null; - } - - /** - * Validate git repository is accessible. - * - * @throws \RuntimeException When repository is not accessible - */ - protected function validateGitRepo(string $repo): void - { - try { - $cwd = getcwd(); - if ($cwd === false) { - throw new \RuntimeException('Could not determine current working directory'); - } - - $process = $this->proc->run( - ['git', 'ls-remote', '--exit-code', $repo], - $cwd, - 10.0 - ); - - if (!$process->isSuccessful()) { - throw new \RuntimeException( - "Cannot access git repository '{$repo}'.\n". - 'Error: '.$process->getErrorOutput() - ); - } - } catch (\Exception $e) { - throw new \RuntimeException( - "Failed to validate git repository '{$repo}'.\n". - 'Error: '.$e->getMessage() - ); - } - } - - /** - * Validate all servers exist in inventory. - * - * @param array $serverNames - * @throws \RuntimeException When any server is not found - */ - protected function validateServers(array $serverNames): void - { - if (count($serverNames) === 0) { - throw new \RuntimeException('At least one server must be selected'); - } - - foreach ($serverNames as $serverName) { - $server = $this->servers->findByName($serverName); - if ($server === null) { - throw new \RuntimeException("Server '{$serverName}' not found in inventory"); - } - } - } -} diff --git a/app/Traits/SitesTrait.php b/app/Traits/SitesTrait.php new file mode 100644 index 00000000..6f808e84 --- /dev/null +++ b/app/Traits/SitesTrait.php @@ -0,0 +1,221 @@ +|null $sites Optional pre-fetched sites; if null, fetches from repository + * @return array|int Returns array of sites or Command::SUCCESS if no sites available + */ + protected function ensureSitesAvailable(?array $sites = null): array|int + { + // + // Get all sites + + $allSites = $sites ?? $this->sites->all(); + + // + // Check if no sites are available + + if (count($allSites) === 0) { + $this->io->warning('No sites found in inventory'); + $this->io->writeln([ + '', + 'Run site:add to add a site', + '', + ]); + + return Command::SUCCESS; + } + + return $allSites; + } + + /** + * Select a site from inventory by domain option or interactive prompt. + * + * @param array|null $sites Optional pre-fetched sites; if null, fetches from repository + * @return SiteDTO|int Returns SiteDTO on success, or Command::SUCCESS if empty inventory, or Command::FAILURE if not found + */ + protected function selectSite(?array $sites = null): SiteDTO|int + { + // + // Get all sites + + $allSites = $this->ensureSitesAvailable($sites); + + if (is_int($allSites)) { + return $allSites; + } + + // + // Extract site domains and prompt for selection + + $siteDomains = array_map(fn (SiteDTO $site) => $site->domain, $allSites); + + $domain = (string) $this->io->getOptionOrPrompt( + 'site', + fn () => $this->io->promptSelect( + label: 'Select site:', + options: $siteDomains, + ) + ); + + // + // Find site by domain + + $site = $this->sites->findByDomain($domain); + + if ($site === null) { + $this->nay("Site '{$domain}' not found in inventory"); + + return Command::FAILURE; + } + + return $site; + } + + /** + * Display site details. + */ + protected function displaySiteDeets(SiteDTO $site): void + { + $details = ['Domain' => $site->domain]; + + if ($site->isLocal()) { + $details['Source'] = 'Local'; + } else { + $details = [ + ...$details, + 'Source' => 'Git', + 'Repo' => $site->repo, + 'Branch' => $site->branch, + ]; + } + + if (count($site->servers) > 1) { + $details['Servers'] = $site->servers; + } elseif (count($site->servers) === 1) { + $details['Server'] = $site->servers[0]; + } + + $this->io->displayDeets($details); + $this->io->writeln(''); + } + + // ------------------------------------------------------------------------------- + // + // Validation + // + // ------------------------------------------------------------------------------- + + /** + * Validate domain format and uniqueness. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSiteDomain(mixed $domain): ?string + { + if (!is_string($domain)) { + return 'Domain must be a string'; + } + + // Check format + $isValid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + if (!$isValid) { + return 'Must be a valid domain name (e.g., example.com, subdomain.example.com)'; + } + + // Check uniqueness + $existing = $this->sites->findByDomain($domain); + if ($existing !== null) { + return "Domain '{$domain}' already exists in inventory"; + } + + return null; + } + + /** + * Validate branch name is not empty. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSiteBranch(mixed $branch): ?string + { + if (!is_string($branch)) { + return 'Branch name must be a string'; + } + + if (trim($branch) === '') { + return 'Branch name cannot be empty'; + } + + return null; + } + + /** + * Validate git repository URL format. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSiteRepo(mixed $repo): ?string + { + if (!is_string($repo)) { + return 'Repository URL must be a string'; + } + + if (trim($repo) === '') { + return 'Repository URL cannot be empty'; + } + + // Basic format check - should start with git@, https://, http://, or ssh:// + $repo = trim($repo); + $validPrefixes = ['git@', 'https://', 'http://', 'ssh://']; + $hasValidPrefix = false; + + foreach ($validPrefixes as $prefix) { + if (str_starts_with($repo, $prefix)) { + $hasValidPrefix = true; + break; + } + } + + if (!$hasValidPrefix) { + return 'Repository URL must start with git@, https://, http://, or ssh://'; + } + + return null; + } +}