diff --git a/app/Console/Key/KeyAddDigitalOceanCommand.php b/app/Console/Key/KeyAddDigitalOceanCommand.php index 5e2b34af..ddb8895d 100644 --- a/app/Console/Key/KeyAddDigitalOceanCommand.php +++ b/app/Console/Key/KeyAddDigitalOceanCommand.php @@ -59,25 +59,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Gather key details - /** @var string|null $keyPath */ - $keyPath = $this->io->getValidatedOptionOrPrompt( + /** @var string|null $publicKeyPathRaw */ + $publicKeyPathRaw = $this->io->getValidatedOptionOrPrompt( 'public-key-path', fn ($validate) => $this->io->promptText( - label: 'Path to SSH public key:', - placeholder: '~/.ssh/id_ed25519.pub', - required: true, + label: 'Path to SSH public key (leave empty for default ~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub):', + default: '', + required: false, + hint: 'Used when provisioning a server', validate: $validate ), fn ($value) => $this->validateKeyPathInput($value) ); - if ($keyPath === null) { + /** @var ?string $publicKeyPath */ + $publicKeyPath = $this->resolvePublicKeyPath($publicKeyPathRaw); + + if ($publicKeyPath === null) { + $this->io->error('SSH public key not found.'); + return Command::FAILURE; } - // Expand tilde to home directory - $keyPath = $this->expandKeyPath($keyPath); - $defaultName = 'deployer-key'; /** @var string|null $keyName */ @@ -102,14 +105,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $keyId = $this->io->promptSpin( - fn () => $this->digitalOcean->key->uploadKey($keyPath, $keyName), + 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('Failed to upload SSH key: ' . $e->getMessage()); + $this->io->error($e->getMessage()); $this->io->writeln(''); return Command::FAILURE; @@ -119,7 +122,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Show command hint $this->io->showCommandHint('key:add:digitalocean', [ - 'public-key-path' => $keyPath, + 'public-key-path' => $publicKeyPath, 'name' => $keyName, ]); diff --git a/app/Services/FilesystemService.php b/app/Services/FilesystemService.php index 5788cd37..ce5617ae 100644 --- a/app/Services/FilesystemService.php +++ b/app/Services/FilesystemService.php @@ -5,6 +5,7 @@ namespace Bigpixelrocket\DeployerPHP\Services; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; /** * Thin wrapper around Symfony Filesystem with gap-filling methods. @@ -103,4 +104,65 @@ public function getParentDirectory(string $path, int $levels = 1): string return dirname($path, $levels); } + + /** + * Expand leading tilde (~) to user's home directory. + * + * @throws \RuntimeException If HOME environment variable not found when needed + */ + public function expandPath(string $path): string + { + if ($path === '' || $path[0] !== '~') { + return $path; + } + + // Determine home directory (POSIX first, then Windows fallbacks) + $home = getenv('HOME') ?: ''; + if ($home === '') { + $home = getenv('USERPROFILE') ?: ''; + if ($home === '') { + $drive = getenv('HOMEDRIVE') ?: ''; + $hpath = getenv('HOMEPATH') ?: ''; + if ($drive !== '' && $hpath !== '') { + $home = $drive . $hpath; + } + } + } + if ($home === '') { + throw new \RuntimeException('Could not determine home directory (HOME/USERPROFILE not set)'); + } + + // Only expand "~" and "~/" (or "~\"); leave "~user" untouched + if ($path === '~') { + return Path::canonicalize($home); + } + if (str_starts_with($path, '~/') || str_starts_with($path, '~\\')) { + return Path::canonicalize($home . substr($path, 1)); + } + return $path; + } + + /** + * Get first existing path from array of candidates. + * Automatically expands tilde paths before checking existence. + * + * @param array $paths Array of file paths to check + * @return string|null First existing path (expanded), or null if none exist + */ + public function getFirstExisting(array $paths): ?string + { + foreach ($paths as $path) { + try { + $expandedPath = $this->expandPath($path); + if ($this->exists($expandedPath)) { + return $expandedPath; + } + } catch (\RuntimeException) { + // Skip paths that cannot be expanded (e.g., ~ paths when HOME not set) + continue; + } + } + + return null; + } } diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php index 1bcba927..77b42fc9 100644 --- a/app/Services/SSHService.php +++ b/app/Services/SSHService.php @@ -14,33 +14,32 @@ * * 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). * * @example - * // Test SSH connectivity - * $ssh->assertCanConnect('example.com', 22, 'deployer'); - * $ssh->assertCanConnect('example.com', 22, 'deployer', '~/.ssh/custom_key'); + * // Test SSH connectivity (commands resolve key paths before calling) + * $ssh->assertCanConnect('example.com', 22, 'deployer', '/home/user/.ssh/id_ed25519'); * * // Execute single commands - * $result = $ssh->executeCommand('example.com', 22, 'deployer', 'uptime'); + * $result = $ssh->executeCommand('example.com', 22, 'deployer', 'uptime', '/home/user/.ssh/id_ed25519'); * 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'); + * $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'); + * $ssh->uploadFile('example.com', 22, 'deployer', './local.txt', '/remote/path/file.txt', '/home/user/.ssh/id_ed25519'); * * // Download files from remote server - * $ssh->downloadFile('example.com', 22, 'deployer', '/remote/config.yml', './local-config.yml'); + * $ssh->downloadFile('example.com', 22, 'deployer', '/remote/config.yml', './local-config.yml', '/home/user/.ssh/id_ed25519'); */ class SSHService { public function __construct( - private readonly EnvService $envService, private readonly FilesystemService $fs, ) { } @@ -54,7 +53,7 @@ public function __construct( * * @throws \RuntimeException When connection or authentication fails */ - public function assertCanConnect(string $host, int $port, string $username, ?string $privateKeyPath = null): void + public function assertCanConnect(string $host, int $port, string $username, string $privateKeyPath): void { $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); $this->disconnect($ssh); @@ -67,7 +66,7 @@ public function assertCanConnect(string $host, int $port, string $username, ?str * * @throws \RuntimeException When connection, authentication, or command execution fails */ - public function executeCommand(string $host, int $port, string $username, string $command, ?string $privateKeyPath = null): array + public function executeCommand(string $host, int $port, string $username, string $command, string $privateKeyPath): array { $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); @@ -93,7 +92,7 @@ public function executeCommand(string $host, int $port, string $username, string * * @throws \RuntimeException When script file cannot be read or execution fails */ - public function executeScript(string $host, int $port, string $username, string $scriptPath, ?string $privateKeyPath = null): array + 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}"); @@ -125,7 +124,7 @@ 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 = null): void + public function uploadFile(string $host, int $port, string $username, string $localPath, string $remotePath, string $privateKeyPath): void { if (!$this->fs->exists($localPath)) { throw new \RuntimeException("Local file does not exist: {$localPath}"); @@ -152,7 +151,7 @@ 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 = null): void + public function downloadFile(string $host, int $port, string $username, string $remotePath, string $localPath, string $privateKeyPath): void { $sftp = $this->createSFTPConnection($host, $port, $username, $privateKeyPath); @@ -179,7 +178,7 @@ public function downloadFile(string $host, int $port, string $username, string $ * * @throws \RuntimeException When connection or authentication fails */ - private function createConnection(string $host, int $port, string $username, ?string $privateKeyPath): SSH2 + private function createConnection(string $host, int $port, string $username, string $privateKeyPath): SSH2 { $key = $this->loadPrivateKey($privateKeyPath); @@ -202,7 +201,7 @@ private function createConnection(string $host, int $port, string $username, ?st * * @throws \RuntimeException When connection or authentication fails */ - private function createSFTPConnection(string $host, int $port, string $username, ?string $privateKeyPath): SFTP + private function createSFTPConnection(string $host, int $port, string $username, string $privateKeyPath): SFTP { $key = $this->loadPrivateKey($privateKeyPath); @@ -246,82 +245,24 @@ private function disconnect(SSH2|SFTP $connection): void * * @throws \RuntimeException When key cannot be found, read, or parsed */ - private function loadPrivateKey(?string $privateKeyPath): PrivateKey + private function loadPrivateKey(string $privateKeyPath): PrivateKey { - $resolvedKeyPath = $this->resolvePrivateKeyPath($privateKeyPath); - - if ($resolvedKeyPath === null) { - throw new \RuntimeException('No SSH private key found. Provide a key path or place a key at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa'); - } - - if (!$this->fs->exists($resolvedKeyPath)) { - throw new \RuntimeException("SSH key does not exist: {$resolvedKeyPath}"); + if (!$this->fs->exists($privateKeyPath)) { + throw new \RuntimeException("SSH key does not exist: {$privateKeyPath}"); } - $keyContents = $this->fs->readFile($resolvedKeyPath); + $keyContents = $this->fs->readFile($privateKeyPath); try { $key = PublicKeyLoader::load($keyContents); } catch (\Throwable $e) { - throw new \RuntimeException("Error parsing SSH private key at {$resolvedKeyPath}: " . $e->getMessage()); + throw new \RuntimeException("Error parsing SSH private key at {$privateKeyPath}: " . $e->getMessage()); } if (!$key instanceof PrivateKey) { - throw new \RuntimeException("File at {$resolvedKeyPath} is not a valid private key"); + throw new \RuntimeException("File at {$privateKeyPath} is not a valid private key"); } return $key; } - - /** - * Resolve a usable private key path. - * - * Priority order: - * 1. Provided path (with ~ expansion) - * 2. ~/.ssh/id_ed25519 - * 3. ~/.ssh/id_rsa - */ - private function resolvePrivateKeyPath(?string $path): ?string - { - $candidates = []; - - // User-provided path takes priority - if (is_string($path) && $path !== '') { - $candidates[] = $this->expandHomePath($path); - } - - // Default SSH key locations - $home = $this->envService->get('HOME', required: false); - if ($home !== null && $home !== '') { - $home = rtrim($home, '/'); - $candidates[] = $home.'/.ssh/id_ed25519'; - $candidates[] = $home.'/.ssh/id_rsa'; - } - - // Return first existing candidate - foreach ($candidates as $candidate) { - if ($this->fs->exists($candidate)) { - return $candidate; - } - } - - return null; - } - - /** - * Expand leading tilde (~) to user's home directory. - */ - private function expandHomePath(string $path): string - { - if ($path === '' || $path[0] !== '~') { - return $path; - } - - $home = $this->envService->get('HOME', required: false); - if ($home === null || $home === '') { - return $path; - } - - return $home.substr($path, 1); - } } diff --git a/app/Traits/DigitalOceanCommandTrait.php b/app/Traits/DigitalOceanCommandTrait.php index c8fb5926..2fa839d8 100644 --- a/app/Traits/DigitalOceanCommandTrait.php +++ b/app/Traits/DigitalOceanCommandTrait.php @@ -4,10 +4,19 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\Services\DigitalOceanService; +use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Services\IOService; use Symfony\Component\Console\Command\Command; /** * Common DigitalOcean actions trait for commands. + * + * Requires classes using this trait to have EnvService, IOService, and DigitalOceanService properties. + * + * @property EnvService $env + * @property IOService $io + * @property DigitalOceanService $digitalOcean */ trait DigitalOceanCommandTrait { diff --git a/app/Traits/KeyHelpersTrait.php b/app/Traits/KeyHelpersTrait.php index ff722699..4c14726a 100644 --- a/app/Traits/KeyHelpersTrait.php +++ b/app/Traits/KeyHelpersTrait.php @@ -4,15 +4,17 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\IOService; use Symfony\Component\Console\Command\Command; /** - * Reusable SSH key-related helpers for commands. + * Common SSH key helpers trait for commands. * - * Requires the using class to extend BaseCommand and have: - * - protected IOService $io - * - protected EnvService $env + * Requires classes using this trait to have IOService and FilesystemService properties. + * + * @property IOService $io + * @property FilesystemService $fs */ trait KeyHelpersTrait { @@ -34,22 +36,59 @@ protected function displayKeyInfo(string $id, string $name, string $fingerprint) } /** - * Expand tilde (~) in file path to absolute home directory path. + * Resolve a usable private key path. * - * @throws \RuntimeException If HOME environment variable not found when needed + * Priority order: + * 1. Provided path (with ~ expansion) + * 2. ~/.ssh/id_ed25519 + * 3. ~/.ssh/id_rsa */ - protected function expandKeyPath(string $path): string + protected function resolvePrivateKeyPath(?string $path): ?string { - if (!str_starts_with($path, '~')) { - return $path; - } + 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', + ]); + } - $home = $this->env->get('HOME', required: false); - if ($home === null) { - throw new \RuntimeException('Could not determine home directory'); + /** + * 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; } - return $home . substr($path, 1); + $candidates = array_merge($candidates, $fallback); + + return $this->fs->getFirstExisting($candidates); } /** diff --git a/app/Traits/KeyValidationTrait.php b/app/Traits/KeyValidationTrait.php index 171b7b4a..f69d337c 100644 --- a/app/Traits/KeyValidationTrait.php +++ b/app/Traits/KeyValidationTrait.php @@ -4,21 +4,23 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\Services\FilesystemService; + /** - * Validation helpers for SSH key configuration. + * Common SSH key validation helpers for commands. + * + * Requires classes using this trait to have a FilesystemService property. * - * Requires the using class to have: - * - protected EnvService $env - * - protected FilesystemService $fs - * - expandKeyPath() method (from KeyHelpersTrait) + * @property FilesystemService $fs */ trait KeyValidationTrait { /** - * Validate SSH public key file path. + * Validate SSH public key file: * - * Checks if file exists and contains valid SSH public key format. - * Automatically expands tilde (~) to home directory. + * - Checks if file exists (automatically expands tilde ~ to home directory) + * - Validates key format + * - Empty paths are allowed for default key resolution * * @return string|null Error message if invalid, null if valid */ @@ -28,16 +30,15 @@ protected function validateKeyPathInput(mixed $path): ?string return 'Key path must be a string'; } - // Check if empty + // Allow empty paths (will trigger default key resolution) if (trim($path) === '') { - return 'Key path cannot be empty'; + return null; } - // Expand tilde to home directory try { - $expandedPath = $this->expandKeyPath($path); - } catch (\RuntimeException) { - return 'Could not determine home directory for path expansion'; + $expandedPath = $this->fs->expandPath($path); + } catch (\Throwable $e) { + return $e->getMessage(); } // Check if file exists