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: 1 addition & 1 deletion .cursor/commands/_review.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules
review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules as well as finding potential bugs and regressions
2 changes: 1 addition & 1 deletion .cursor/commands/review-branch.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Meticulously catalog and analyze all the changes in this branch, compared to the branch it's based on.

Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules.
Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules as well as finding potential bugs and regressions

Provide a detailed report but don't make any changes yet.
2 changes: 1 addition & 1 deletion .cursor/commands/review-diff.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged.

Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules.
Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules as well as finding potential bugs and regressions.

Provide a detailed report but don't make any changes yet.
7 changes: 7 additions & 0 deletions app/Contracts/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Bigpixelrocket\DeployerPHP\Contracts;

use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Services\EnvService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait;
Expand Down Expand Up @@ -34,6 +35,7 @@ public function __construct(
protected readonly Container $container,
protected readonly EnvService $env,
protected readonly InventoryService $inventory,
protected readonly ServerRepository $servers,
) {
parent::__construct();
}
Expand Down Expand Up @@ -90,6 +92,11 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
$customInventoryPath = $input->getOption('inventory');
$this->inventory->setCustomPath($customInventoryPath);
$this->inventory->loadInventoryFile();

//
// Initialize repositories

$this->servers->loadInventory($this->inventory);
}

//
Expand Down
17 changes: 17 additions & 0 deletions app/DTOs/ServerDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\DTOs;

readonly class ServerDTO
{
public function __construct(
public string $name,
public string $host,
public int $port = 22,
public string $username = 'root',
public ?string $privateKeyPath = null,
) {
}
}
Comment on lines +7 to +17

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

Add class-level DocBlock.

The ServerDTO class is missing a DocBlock comment. As per coding guidelines, all classes should have DocBlock comments with minimalist descriptions.

Apply this diff to add a DocBlock:

+/**
+ * Server configuration data transfer object.
+ */
 readonly class ServerDTO
 {
📝 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
readonly class ServerDTO
{
public function __construct(
public string $name,
public string $host,
public int $port = 22,
public string $username = 'root',
public ?string $privateKeyPath = null,
) {
}
}
/**
* Server configuration data transfer object.
*/
readonly class ServerDTO
{
public function __construct(
public string $name,
public string $host,
public int $port = 22,
public string $username = 'root',
public ?string $privateKeyPath = null,
) {
}
}
🤖 Prompt for AI Agents
In app/DTOs/ServerDTO.php around lines 7 to 17, the readonly ServerDTO class
lacks a class-level DocBlock; add a minimalist DocBlock immediately above the
class declaration containing a one-line description of the DTO (e.g., "Data
transfer object representing an SSH server") and optional @package or @author
tags per project conventions, ensuring standard PHPDoc syntax (/** ... */).

168 changes: 168 additions & 0 deletions app/Repositories/ServerRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Repositories;

use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;

/**
* Repository for server CRUD operations using inventory storage.
*
* Stores servers as an array of objects to handle any special characters in server names.
*/
final class ServerRepository
{
private const PREFIX = 'servers';

private ?InventoryService $inventory = null;

/** @var array<int, array<string, mixed>> */
private array $servers = [];

//
// Public
// -------------------------------------------------------------------------------

/**
* Set the inventory service instance to use for storage operations.
*/
public function loadInventory(InventoryService $inventory): void
{
$this->inventory = $inventory;

$servers = $inventory->get(self::PREFIX);
if (!is_array($servers)) {
$servers = [];
$inventory->set(self::PREFIX, $servers);
}

/** @var array<int, array<string, mixed>> $servers */
$this->servers = $servers;
}

/**
* Create a new server in the inventory.
*/
public function create(ServerDTO $server): void
{
$this->assertInventoryLoaded();

$existing = $this->findByName($server->name);
if (null !== $existing) {
throw new \RuntimeException("Server '{$server->name}' already exists");
}

$this->servers[] = $this->dehydrateServerDTO($server);

$this->inventory->set(self::PREFIX, $this->servers);
}

/**
* Find a server by name.
*/
public function findByName(string $name): ?ServerDTO
{
$this->assertInventoryLoaded();

foreach ($this->servers as $server) {
if (isset($server['name']) && $server['name'] === $name) {
return $this->hydrateServerDTO($server);
}
}

return null;
}

/**
* Get all servers from the inventory.
*
* @return array<int, ServerDTO>
*/
public function all(): array
{
$this->assertInventoryLoaded();

$result = [];
foreach ($this->servers as $server) {
$result[] = $this->hydrateServerDTO($server);
}

return $result;
}

/**
* Delete a server from the inventory.
*/
public function delete(string $name): void
{
$this->assertInventoryLoaded();

$filtered = [];
foreach ($this->servers as $server) {
if (isset($server['name']) && $server['name'] !== $name) {
$filtered[] = $server;
}
}

$this->servers = $filtered;

$this->inventory->set(self::PREFIX, $this->servers);
}

//
// Private
// -------------------------------------------------------------------------------

/**
* Ensure inventory service is loaded before operations.
*
* @throws \RuntimeException If inventory is not set
* @phpstan-assert !null $this->inventory
*/
private function assertInventoryLoaded(): void
{
if ($this->inventory === null) {
throw new \RuntimeException('Inventory not set. Call loadInventory() first.');
}
}

/**
* Convert ServerDTO to array for storage.
*
* @return array<string, mixed>
*/
private function dehydrateServerDTO(ServerDTO $server): array
{
return [
'name' => $server->name,
'host' => $server->host,
'port' => $server->port,
'username' => $server->username,
'privateKeyPath' => $server->privateKeyPath,
];
}

/**
* Hydrate a ServerDTO from inventory data.
*
* @param array<string, mixed> $data
*/
private function hydrateServerDTO(array $data): ServerDTO
{
$name = $data['name'] ?? '';
$host = $data['host'] ?? '';
$port = $data['port'] ?? 22;
$username = $data['username'] ?? 'root';
$privateKeyPath = $data['privateKeyPath'] ?? null;

return new ServerDTO(
name: is_string($name) ? $name : '',
host: is_string($host) ? $host : '',
port: is_int($port) ? $port : 22,
username: is_string($username) ? $username : 'root',
privateKeyPath: is_string($privateKeyPath) ? $privateKeyPath : null,
);
}
}
20 changes: 10 additions & 10 deletions app/Services/InventoryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,24 @@
*
* @example
* // Store values using dot notation
* $inventory->set('servers.production.host', 'example.com');
* $inventory->set('servers.production.user', 'deployer');
* $inventory->set('widgets.alpha.color', 'blue');
* $inventory->set('widgets.alpha.size', 'large');
*
* // Or set entire object at once
* $inventory->set('servers.production', ['host' => 'example.com', 'user' => 'deployer']);
* $inventory->set('widgets.alpha', ['color' => 'blue', 'size' => 'large']);
*
* // Retrieve values at any depth
* $inventory->get('servers.production.host'); // 'example.com'
* $inventory->get('servers.production'); // ['host' => 'example.com', 'user' => 'deployer']
* $inventory->get('servers'); // ['production' => ['host' => 'example.com', 'user' => 'deployer']]
* $inventory->get('widgets.alpha.color'); // 'blue'
* $inventory->get('widgets.alpha'); // ['color' => 'blue', 'size' => 'large']
* $inventory->get('widgets'); // ['alpha' => ['color' => 'blue', 'size' => 'large']]
*
* // Default values when path doesn't exist
* $inventory->get('servers.staging'); // null
* $inventory->get('servers.staging', []); // []
* $inventory->get('servers.staging.host', 'localhost'); // 'localhost'
* $inventory->get('widgets.beta'); // null
* $inventory->get('widgets.beta', []); // []
* $inventory->get('widgets.beta.color', 'red'); // 'red'
*
* // Delete path
* $inventory->delete('servers.production');
* $inventory->delete('widgets.alpha');
*/
class InventoryService
{
Expand Down
4 changes: 3 additions & 1 deletion tests/Fixtures/TestConsoleCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Services\EnvService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
use Symfony\Component\Console\Command\Command;
Expand All @@ -28,8 +29,9 @@ public function __construct(
Container $container,
EnvService $env,
InventoryService $inventory,
ServerRepository $servers,
) {
parent::__construct($container, $env, $inventory);
parent::__construct($container, $env, $inventory, $servers);
}

/**
Expand Down
23 changes: 22 additions & 1 deletion tests/TestHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Services\EnvService;
use Bigpixelrocket\DeployerPHP\Services\FilesystemService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
Expand Down Expand Up @@ -163,7 +164,7 @@ function mockInventoryService(
if (is_array($data)) {
$fileContent = empty($data) ? '' : Yaml::dump($data, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
} else {
$defaultContent = 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com';
$defaultContent = 'widgets:' . PHP_EOL . ' alpha:' . PHP_EOL . ' color: red';
$fileContent = $data ?: ($fileExists ? $defaultContent : '');
}

Expand Down Expand Up @@ -229,3 +230,23 @@ function mockVersionService(
return new VersionService($processFactory, $filesystemService);
}
}

if (!function_exists('mockServerRepository')) {
/**
* Create a ServerRepository for testing with a loaded inventory service.
*/
function mockServerRepository(
bool $fileExists = true,
array|string $data = '',
bool $throwOnRead = false,
bool $throwOnWrite = false
): ServerRepository {
$inventory = mockInventoryService($fileExists, $data, $throwOnRead, $throwOnWrite);
$inventory->loadInventoryFile();

$repository = new ServerRepository();
$repository->loadInventory($inventory);

return $repository;
}
}
10 changes: 7 additions & 3 deletions tests/Unit/Contracts/BaseCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Services\EnvService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
use Symfony\Component\Console\Command\Command;
Expand All @@ -25,9 +26,10 @@ public function __construct(
Container $container,
EnvService $env,
InventoryService $inventory,
ServerRepository $servers,
private readonly string $testName = 'test-command',
) {
parent::__construct($container, $env, $inventory);
parent::__construct($container, $env, $inventory, $servers);
}

protected function configure(): void
Expand All @@ -54,9 +56,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$container = new Container();
$env = mockEnvService(true);
$inventory = mockInventoryService(true);
$servers = mockServerRepository();

// ACT
$command = new TestableBaseCommand($container, $env, $inventory, 'test');
$command = new TestableBaseCommand($container, $env, $inventory, $servers, 'test');

// ASSERT
expect($command->getName())->toBe('test')
Expand All @@ -73,7 +76,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$container = new Container();
$env = mockEnvService($hasEnvFile);
$inventory = mockInventoryService(true);
$command = new TestableBaseCommand($container, $env, $inventory);
$servers = mockServerRepository();
$command = new TestableBaseCommand($container, $env, $inventory, $servers);
$tester = new CommandTester($command);

// ACT
Expand Down
Loading