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
2 changes: 2 additions & 0 deletions .cursor/rules/01-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,5 @@ vendor/bin/pint $CHANGED_PHP_FILES # Fix code style (changed files only)
# Static analysis excluding tests (never do static analysis against tests)
vendor/bin/phpstan analyze $CHANGED_PHP_FILES_EXCEPT_TESTS
```

**Important: ** Don't run PHPStan on test files; tests are excluded from static analysis.
2 changes: 2 additions & 0 deletions .cursor/rules/02-tests.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,7 @@ $mock->shouldReceive('method')->with('param')->andReturn('result');

### Static Analysis

**Running PHPStan applies to PRODUCTION code, not tests.**

- Ignore PHPStan issues in tests - focus on test functionality over compliance
- Avoid excessive phpdoc just to appease types
246 changes: 246 additions & 0 deletions app/Console/Server/ServerAddCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Console\Server;

use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait;
use Bigpixelrocket\DeployerPHP\Traits\ServerValidationTrait;
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 server to the inventory.
*
* Prompts for server details and verifies SSH connectivity before saving.
*/
#[AsCommand(name: 'server:add', description: 'Add a new server to the inventory')]
class ServerAddCommand extends BaseCommand
{
use ServerHelpersTrait;
use ServerValidationTrait;

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

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

$this
->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name')
->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address')
->addOption('port', null, InputOption::VALUE_REQUIRED, 'SSH port (default: 22)')
->addOption('username', null, InputOption::VALUE_REQUIRED, 'SSH username (default: root)')
->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path')
->addOption('skip', null, InputOption::VALUE_NONE, 'Skip SSH connection check')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt');
}

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

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

$this->hr();

$this->h1('Add New Server');

//
// Gather server details

/** @var string $name */
$name = $this->getOptionOrPrompt(
'name',
fn (): string => $this->promptText(
label: 'Server name:',
placeholder: 'web1',
required: true
)
);

/** @var string $host */
$host = $this->getOptionOrPrompt(
'host',
fn (): string => $this->promptText(
label: 'Host/IP address:',
placeholder: '192.168.1.100',
required: true
)
);

$this->validateHost($host);

/** @var string $portString */
$portString = $this->getOptionOrPrompt(
'port',
fn (): string => $this->promptText(
label: 'SSH port:',
default: '22',
required: true
)
);

$port = (int) $portString;
$this->validatePort($port);

/** @var string $username */
$username = $this->getOptionOrPrompt(
'username',
fn (): string => $this->promptText(
label: 'SSH username:',
default: 'root',
required: true
)
);

/** @var string $privateKeyPathRaw */
$privateKeyPathRaw = $this->getOptionOrPrompt(
'private-key-path',
fn (): string => $this->promptText(
label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):',
default: '',
required: false
)
);

/** @var ?string $privateKeyPath */
$privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null;

//
// Create DTO and display server info

$server = new ServerDTO(
name: $name,
host: $host,
port: $port,
username: $username,
privateKeyPath: $privateKeyPath
);

$this->hr();

$this->displayServerInfo($server);

//
// Verify connectivity

/** @var bool $skipCheck */
$skipCheck = $this->getOptionOrPrompt(
'skip',
fn (): bool => !$this->promptConfirm(
label: 'Test SSH connection before saving?',
default: true
)
);

if ($skipCheck) {
$this->warning('Skipping SSH connection check');
$this->writeln('');
} else {
if (!$this->testConnection($server)) {
return Command::FAILURE;
}
}
Comment on lines +107 to +153

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 | 🟠 Major

Allow non-interactive runs to skip the key prompt

Calling getOptionOrPrompt('private-key-path', …) always triggers a prompt when the option is omitted. In non-interactive workflows (CI, automation) the command hangs/fails even with --yes/--skip, so users cannot rely on the default key lookup. Please fall back to the prompt only when the input is interactive; otherwise treat the missing option as null.

-        /** @var string $privateKeyPathRaw */
-        $privateKeyPathRaw = $this->getOptionOrPrompt(
-            'private-key-path',
-            fn (): string => $this->promptText(
-                label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):',
-                default: '',
-                required: false
-            )
-        );
-
-        /** @var ?string $privateKeyPath */
-        $privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null;
+        /** @var string|null $privateKeyPathOption */
+        $privateKeyPathOption = $this->input->getOption('private-key-path');
+
+        if (is_string($privateKeyPathOption) && $privateKeyPathOption !== '') {
+            $privateKeyPath = $privateKeyPathOption;
+        } elseif ($this->input->isInteractive()) {
+            $privateKeyPathRaw = $this->promptText(
+                label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):',
+                default: '',
+                required: false
+            );
+            $privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null;
+        } else {
+            $privateKeyPath = null;
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$privateKeyPathRaw = $this->getOptionOrPrompt(
'private-key-path',
fn (): string => $this->promptText(
label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):',
default: '',
required: false
)
);
/** @var ?string $privateKeyPath */
$privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null;
//
// Create DTO and display server info
$server = new ServerDTO(
name: $name,
host: $host,
port: $port,
username: $username,
privateKeyPath: $privateKeyPath
);
$this->hr();
$this->displayServerInfo($server);
//
// Verify connectivity
/** @var bool $skipCheck */
$skipCheck = $this->getOptionOrPrompt(
'skip',
fn (): bool => !$this->promptConfirm(
label: 'Test SSH connection before saving?',
default: true
)
);
if ($skipCheck) {
$this->warning('Skipping SSH connection check');
$this->writeln('');
} else {
if (!$this->testConnection($server)) {
return Command::FAILURE;
}
}
/** @var string|null $privateKeyPathOption */
$privateKeyPathOption = $this->input->getOption('private-key-path');
if (is_string($privateKeyPathOption) && $privateKeyPathOption !== '') {
$privateKeyPath = $privateKeyPathOption;
} elseif ($this->input->isInteractive()) {
$privateKeyPathRaw = $this->promptText(
label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):',
default: '',
required: false
);
$privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null;
} else {
$privateKeyPath = null;
}
//
// Create DTO and display server info
$server = new ServerDTO(
name: $name,
host: $host,
port: $port,
username: $username,
privateKeyPath: $privateKeyPath
);
🤖 Prompt for AI Agents
In app/Console/Server/ServerAddCommand.php around lines 107-153, the private-key
prompt is always invoked when the option is omitted, blocking non-interactive
runs; change the logic to first read the option directly and only call the
interactive prompt when the input is actually interactive: retrieve the raw
option value (null/empty if not provided), and if it's null and
$this->input->isInteractive() is true then call getOptionOrPrompt to prompt the
user; otherwise treat missing/empty option as null and proceed (preserving the
existing conversion from empty string to null and the rest of the flow).


//
// Confirm creation

/** @var bool $confirmed */
$confirmed = $this->getOptionOrPrompt(
'yes',
fn (): bool => $this->promptConfirm(
label: 'Save this server to inventory?',
default: true
)
);

if (!$confirmed) {
$this->warning('Cancelled adding server');
$this->writeln('');

return Command::SUCCESS;
}

//
// Save to repository

try {
$this->servers->create($server);
} catch (\RuntimeException $e) {
$this->error('Failed to add server: ' . $e->getMessage());

return Command::FAILURE;
}

$this->success('Server added successfully');
$this->writeln('');

//
// Show command hint

$this->showCommandHint('server:add', [
'name' => $name,
'host' => $host,
'port' => $port,
'username' => $username,
'private-key-path' => $privateKeyPath,
'skip' => $skipCheck,
'yes' => $confirmed,
]);

return Command::SUCCESS;
}

//
// Private Helpers
// -------------------------------------------------------------------------------

/**
* Test SSH connection to server with detailed output.
*/
private function testConnection(ServerDTO $server): bool
{
try {
$this->promptSpin(
callback: fn () => $this->ssh->assertCanConnect(
$server->host,
$server->port,
$server->username,
$server->privateKeyPath
),
message: 'Connecting to server...'
);

$this->success('SSH connection successful');

return true;
} catch (\RuntimeException $e) {
$this->error($e->getMessage());

$this->writeln([
'',
' <fg=yellow>Common issues:</>',
'',
' <fg=gray>• Check that the server is accessible from your network</>',
' <fg=gray>• Verify SSH is running on the server (port '.$server->port.')</>',
' <fg=gray>• Ensure your SSH key has correct permissions (chmod 600)</>',
' <fg=gray>• Confirm username "'.$server->username.'" exists on the server</>',
'',
' <fg=gray>Tip: Use</> <fg=cyan>--skip</> <fg=gray>to add server without testing connection.</>',
'',
]);

return false;
}
}
}
133 changes: 133 additions & 0 deletions app/Console/Server/ServerDeleteCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Console\Server;

use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait;
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 server from the inventory.
*/
#[AsCommand(name: 'server:delete', description: 'Delete a server from the inventory')]
class ServerDeleteCommand extends BaseCommand
{
use ServerHelpersTrait;

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

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

$this
->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt');
}

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

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

$this->hr();

//
// Get all servers

$allServers = $this->servers->all();
if (count($allServers) === 0) {
$this->warning('No servers found in inventory');
$this->writeln([
'',
'Use <fg=cyan>server:add</> to add a server',
'',
]);

return Command::SUCCESS;
}

// Extract server names from DTOs for promptSelect
$serverNames = array_map(fn (ServerDTO $server) => $server->name, $allServers);

//
// Select server to delete

$this->h1('Delete Server');

$name = (string) $this->getOptionOrPrompt(
'name',
fn () => $this->promptSelect(
label: 'Select server:',
options: $serverNames,
)
);

//
// Find server and display info

$server = null;
foreach ($allServers as $s) {
if ($s->name === $name) {
$server = $s;
break;
}
}

if ($server === null) {
$this->error("Server '{$name}' not found in inventory");
return Command::FAILURE;
}

$this->displayServerInfo($server);
Comment on lines +81 to +94

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.

🛠️ Refactor suggestion | 🟠 Major

Use repository method instead of manual lookup.

Lines 82-87 manually loop through servers to find a match by name, duplicating logic that already exists in ServerRepository::findByName().

Apply this diff to use the repository method:

-    //
-    // Find server and display info
-
-    $server = null;
-    foreach ($allServers as $s) {
-        if ($s->name === $name) {
-            $server = $s;
-            break;
-        }
-    }
+    //
+    // Find server and display info
+
+    $server = $this->servers->findByName($name);

     if ($server === null) {
         $this->error("Server '{$name}' not found in inventory");
         return Command::FAILURE;
     }

This reduces duplication and improves maintainability by relying on the repository's existing method.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$server = null;
foreach ($allServers as $s) {
if ($s->name === $name) {
$server = $s;
break;
}
}
if ($server === null) {
$this->error("Server '{$name}' not found in inventory");
return Command::FAILURE;
}
$this->displayServerInfo($server);
//
// Find server and display info
$server = $this->servers->findByName($name);
if ($server === null) {
$this->error("Server '{$name}' not found in inventory");
return Command::FAILURE;
}
$this->displayServerInfo($server);
🤖 Prompt for AI Agents
In app/Console/Server/ServerDeleteCommand.php around lines 81 to 94, replace the
manual foreach lookup for a server by name with a call to the repository's
findByName() method; call $this->serverRepository->findByName($name), assign the
result to $server, then keep the existing null check and error return and the
subsequent $this->displayServerInfo($server) so behavior remains identical but
avoids duplicating lookup logic.


//
// Confirm deletion

/** @var bool $confirmed */
$confirmed = $this->getOptionOrPrompt(
'yes',
fn (): bool => $this->promptConfirm(
label: 'Are you sure you want to delete this server?',
default: true
)
);

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

return Command::SUCCESS;
}

//
// Delete server

$this->servers->delete($name);

$this->success("Server '{$name}' deleted successfully");
$this->writeln('');

//
// Show command hint

$this->showCommandHint('server:delete', [
'name' => $name,
'yes' => $confirmed,
]);

return Command::SUCCESS;
}
}
Loading