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
15 changes: 15 additions & 0 deletions .cursor/rules/01-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ All rules MANDATORY.
- Explicit return types with generics: `Collection<int, User>`
- 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

Expand Down
6 changes: 3 additions & 3 deletions .cursor/rules/03-commands.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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,
]);
Expand Down
7 changes: 5 additions & 2 deletions app/Console/HelloCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/**
Expand All @@ -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;
}
Expand Down
117 changes: 74 additions & 43 deletions app/Console/Key/KeyAddDigitalOceanCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,24 +37,83 @@ 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;
}

//
// 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',
Expand All @@ -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';
Expand All @@ -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,
];
}
}
Loading