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
128 changes: 128 additions & 0 deletions app/Console/Key/KeyAddDigitalOceanCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

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 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;

//
// Configuration
// -------------------------------------------------------------------------------

protected function configure(): void
{
parent::configure();

$this
->addOption('name', null, InputOption::VALUE_REQUIRED, 'Key name in DigitalOcean account')
->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');

if ($this->initializeDigitalOceanAPI() === Command::FAILURE) {
return Command::FAILURE;
}

//
// Gather key details

/** @var string|null $keyPath */
$keyPath = $this->io->getValidatedOptionOrPrompt(
'public-key-path',
fn ($validate) => $this->io->promptText(
label: 'Path to SSH public key:',
placeholder: '~/.ssh/id_ed25519.pub',
required: true,
validate: $validate
),
fn ($value) => $this->validateKeyPathInput($value)
);

if ($keyPath === null) {
return Command::FAILURE;
}

// Expand tilde to home directory
$keyPath = $this->expandKeyPath($keyPath);

$defaultName = 'deployer-key';

/** @var string|null $keyName */
$keyName = $this->io->getValidatedOptionOrPrompt(
'name',
fn ($validate) => $this->io->promptText(
label: 'Key name:',
placeholder: $defaultName,
default: $defaultName,
required: true,
validate: $validate
),
fn ($value) => $this->validateKeyNameInput($value)
);

if ($keyName === null) {
return Command::FAILURE;
}

//
// Upload SSH key

try {
$keyId = $this->io->promptSpin(
fn () => $this->digitalOcean->key->uploadKey($keyPath, $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->writeln('');

return Command::FAILURE;
}

//
// Show command hint

$this->io->showCommandHint('key:add:digitalocean', [
'public-key-path' => $keyPath,
'name' => $keyName,
]);

return Command::SUCCESS;
}
}
159 changes: 159 additions & 0 deletions app/Console/Key/KeyDeleteDigitalOceanCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Console\Key;

use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait;
use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait;
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'
)]
class KeyDeleteDigitalOceanCommand extends BaseCommand
{
use DigitalOceanCommandTrait;
use KeyHelpersTrait;

//
// Configuration
// -------------------------------------------------------------------------------

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');
}

//
// 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;
}

//
// 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('');

return Command::FAILURE;
}

//
// Select key

$selection = $this->selectKey($availableKeys);

if ($selection['key'] === null) {
return $selection['exit_code'];
}

$keyId = (int) $selection['key'];
$keyDescription = $availableKeys[$keyId];

//
// Display key details

$this->io->hr();

$this->io->writeln([
" ID: <fg=gray>{$keyId}</>",
" Name: <fg=gray>{$keyDescription}</>",
'',
]);

//
// Confirm deletion with extra safety

/** @var bool $forceSkip */
$forceSkip = $input->getOption('force') ?? false;

if (!$forceSkip) {
$this->io->writeln('');

$typedKeyId = $this->io->promptText(
label: "Type the key ID '{$keyId}' to confirm deletion:",
required: true
);

if ($typedKeyId !== (string) $keyId) {
$this->io->error('Key ID does not match. Deletion cancelled.');
$this->io->writeln('');

return Command::FAILURE;
}
}

/** @var bool $confirmed */
$confirmed = $this->io->getOptionOrPrompt(
'yes',
fn (): bool => $this->io->promptConfirm(
label: 'Are you absolutely sure?',
default: false
)
);

if (!$confirmed) {
$this->io->warning('Cancelled deleting SSH key');
$this->io->writeln('');

return Command::SUCCESS;
}

//
// Delete key

try {
$this->io->promptSpin(
fn () => $this->digitalOcean->key->deleteKey($keyId),
'Deleting SSH key...'
);

$this->io->success('SSH key deleted successfully');
$this->io->writeln('');
} catch (\RuntimeException $e) {
$this->io->error('Failed to delete SSH key: ' . $e->getMessage());
$this->io->writeln('');

return Command::FAILURE;
}

//
// Show command hint

$this->io->showCommandHint('key:delete:digitalocean', [
'key' => (string) $keyId,
'yes' => $confirmed,
'force' => true,
]);
Comment on lines +151 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Command hint always shows --force regardless of actual usage.

Line 154 hardcodes 'force' => true in the command hint, even when the user didn't use the --force flag. This could mislead users into thinking they used --force when they went through typed confirmation.

Apply this diff to show the actual force flag state:

     $this->io->showCommandHint('key:delete:digitalocean', [
         'key' => (string) $keyId,
         'yes' => $confirmed,
-        'force' => true,
+        'force' => $forceSkip,
     ]);
🤖 Prompt for AI Agents
In app/Console/Key/KeyDeleteDigitalOceanCommand.php around lines 151 to 155, the
command hint hardcodes 'force' => true which inaccurately shows --force even
when not used; replace the hardcoded true with the actual force flag state (e.g.
use the command's option or the local $confirmed/$force variable) so the hint
reflects whether --force was provided.


return Command::SUCCESS;
}
}
79 changes: 79 additions & 0 deletions app/Console/Key/KeyListDigitalOceanCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Console\Key;

use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait;
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'
)]
class KeyListDigitalOceanCommand extends BaseCommand
{
use DigitalOceanCommandTrait;

//
// Execution
// -------------------------------------------------------------------------------

protected function execute(InputInterface $input, OutputInterface $output): int
{
parent::execute($input, $output);

$this->io->hr();
$this->io->h1('List SSH Keys in DigitalOcean');

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('');

return Command::FAILURE;
}

//
// Display keys
// -------------------------------------------------------------------------------

if (count($keys) === 0) {
$this->io->warning('No SSH keys found in your DigitalOcean account');
$this->io->writeln([
'',
'Use <fg=cyan>key:add:digitalocean</> to add an SSH key',
'',
]);

return Command::SUCCESS;
}

foreach ($keys as $keyId => $description) {
$this->io->writeln(" <fg=cyan>{$keyId}</> - {$description}");
}

$this->io->writeln('');

return Command::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion app/Contracts/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function __construct(
protected readonly SiteRepository $sites,
protected readonly SSHService $ssh,

// Providers
// Hosting providers
protected readonly DigitalOceanService $digitalOcean,
) {
parent::__construct();
Expand Down
Loading
Loading