Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions app/Console/Key/KeyAddDigitalOceanCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
Expand All @@ -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,
]);

Expand Down
62 changes: 62 additions & 0 deletions app/Services/FilesystemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Comment thread
loadinglucian marked this conversation as resolved.

/**
* Get first existing path from array of candidates.
* Automatically expands tilde paths before checking existence.
*
* @param array<int, string> $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;
}
}
99 changes: 20 additions & 79 deletions app/Services/SSHService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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}");
Expand Down Expand Up @@ -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}");
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);
}
}
9 changes: 9 additions & 0 deletions app/Traits/DigitalOceanCommandTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*/
trait DigitalOceanCommandTrait
{
Expand Down
Loading
Loading