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
31 changes: 28 additions & 3 deletions app/Contracts/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository;
use Bigpixelrocket\DeployerPHP\Services\EnvService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
use Bigpixelrocket\DeployerPHP\Services\ProcessService;
use Bigpixelrocket\DeployerPHP\Services\PrompterService;
use Bigpixelrocket\DeployerPHP\Services\SSHService;
use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait;
Expand All @@ -33,13 +35,27 @@ abstract class BaseCommand extends Command
protected OutputInterface $output;
protected SymfonyStyle $io;

/**
* Create a new BaseCommand with the application's services and repositories.
*
* The constructor accepts and stores dependencies (environment and inventory services,
* process and prompting helpers, server/site repositories, SSH service, and the DI container)
* used by this command and its subclasses.
*/
public function __construct(
// Framework
protected readonly Container $container,

// Base services
protected readonly EnvService $env,
protected readonly InventoryService $inventory,
protected readonly ProcessService $proc,
protected readonly PrompterService $prompter,

// Servers & sites
protected readonly ServerRepository $servers,
protected readonly SiteRepository $sites,
protected readonly SSHService $ssh,
protected readonly PrompterService $prompter,
) {
parent::__construct();
}
Expand Down Expand Up @@ -71,7 +87,15 @@ protected function configure(): void
}

/**
* Initialize IO and services.
* Prepare console IO and initialize environment, inventory, and repositories.
*
* Sets the command's input/output properties, creates a SymfonyStyle IO helper,
* applies any custom paths provided via the `--env` and `--inventory` options,
* loads the corresponding files, and populates the servers and sites repositories
* from the loaded inventory.
*
* @param InputInterface $input The current console input.
* @param OutputInterface $output The current console output.
*/
protected function initialize(InputInterface $input, OutputInterface $output): void
{
Expand Down Expand Up @@ -101,6 +125,7 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
// Initialize repositories

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

//
Expand Down Expand Up @@ -132,4 +157,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int

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

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\DTOs;

readonly class SiteDTO
{
/**
* Create a SiteDTO containing the site's domain, repository, branch, and associated servers.
*
* @param string $domain The site's domain name (e.g. example.com).
* @param string $repo The repository URL or identifier for the site.
* @param string $branch The repository branch to deploy (e.g. main).
* @param array<int, string> $servers Ordered list of server hostnames or addresses associated with the site.
*/
public function __construct(
public string $domain,
public string $repo,
public string $branch,
public array $servers,
) {
}
}
181 changes: 181 additions & 0 deletions app/Repositories/SiteRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Repositories;

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

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

private ?InventoryService $inventory = null;

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

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

/**
* Configure the repository with an InventoryService and load site entries from storage.
*
* Loads the value stored under the repository's PREFIX key into the internal sites cache
* ($this->sites). If the stored value is not an array, an empty array is persisted under
* the PREFIX key and loaded into the cache.
*/
public function loadInventory(InventoryService $inventory): void
{
$this->inventory = $inventory;

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

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

/**
* Add a new site to the inventory storage ensuring the site's domain is unique.
*
* @param SiteDTO $site The site to store; its domain must not already exist in inventory.
* @throws \RuntimeException If the inventory has not been loaded or a site with the same domain already exists.
*/
public function create(SiteDTO $site): void
{
$this->assertInventoryLoaded();

$existing = $this->findByDomain($site->domain);
if (null !== $existing) {
throw new \RuntimeException("Site '{$site->domain}' already exists");
}

$this->sites[] = $this->dehydrateSiteDTO($site);

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

/**
* Retrieve the site matching the given domain.
*
* @throws \RuntimeException If the inventory has not been loaded via loadInventory().
* @return SiteDTO|null The SiteDTO for the matching domain, or `null` if no match is found.
*/
public function findByDomain(string $domain): ?SiteDTO
{
$this->assertInventoryLoaded();

foreach ($this->sites as $site) {
if (isset($site['domain']) && $site['domain'] === $domain) {
return $this->hydrateSiteDTO($site);
}
}

return null;
}

/**
* Retrieve all stored sites as SiteDTO objects.
*
* @return array<int, SiteDTO> An array of SiteDTO objects.
*/
public function all(): array
{
$this->assertInventoryLoaded();

$result = [];
foreach ($this->sites as $site) {
$result[] = $this->hydrateSiteDTO($site);
}

return $result;
}

/**
* Remove the site with the given domain from the stored inventory.
*
* If no site matches the domain, the inventory remains unchanged.
*
* @param string $domain The domain of the site to remove.
*/
public function delete(string $domain): void
{
$this->assertInventoryLoaded();

$filtered = [];
foreach ($this->sites as $site) {
if (isset($site['domain']) && $site['domain'] !== $domain) {
$filtered[] = $site;
}
}

$this->sites = $filtered;

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

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

/**
* Asserts that the repository's inventory service has been loaded.
*
* @throws \RuntimeException If the inventory service has not been loaded.
* @phpstan-assert !null $this->inventory
*/
private function assertInventoryLoaded(): void
{
if ($this->inventory === null) {
throw new \RuntimeException('Inventory not set. Call loadInventory() first.');
}
}

/**
* Serialize a SiteDTO into an associative array suitable for inventory storage.
*
* @param SiteDTO $site The site DTO to serialize.
* @return array<string, mixed> Associative array with keys `domain`, `repo`, `branch`, and `servers`.
*/
private function dehydrateSiteDTO(SiteDTO $site): array
{
return [
'domain' => $site->domain,
'repo' => $site->repo,
'branch' => $site->branch,
'servers' => $site->servers,
];
}

/**
* Create a SiteDTO from raw inventory data.
*
* @param array<string,mixed> $data Raw associative array from inventory.
* @return SiteDTO A SiteDTO where `domain`, `repo`, and `branch` are strings (empty string if missing or not a string) and `servers` is an array of strings (empty array if missing or invalid).
*/
private function hydrateSiteDTO(array $data): SiteDTO
{
$domain = $data['domain'] ?? '';
$repo = $data['repo'] ?? '';
$branch = $data['branch'] ?? '';
$servers = $data['servers'] ?? [];

return new SiteDTO(
domain: is_string($domain) ? $domain : '',
repo: is_string($repo) ? $repo : '',
branch: is_string($branch) ? $branch : '',
servers: is_array($servers) ? array_values(array_filter($servers, 'is_string')) : [],
);
}
}
39 changes: 0 additions & 39 deletions app/Services/ProcessFactory.php

This file was deleted.

49 changes: 49 additions & 0 deletions app/Services/ProcessService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Services;

use Symfony\Component\Process\Process;

/**
* Service for executing local shell commands with consistent configuration.
*/
final readonly class ProcessService
{
/**
* Initialize the service with a filesystem utility used for directory validation and inspection.
*
* @param FilesystemService $fs Filesystem utility used to validate working directories and perform filesystem checks.
*/
public function __construct(
private FilesystemService $fs,
) {
}

/**
* Execute the given command in the specified working directory and return the executed Process instance.
*
* @param list<string> $command The command and its arguments.
* @param string $cwd The working directory in which to execute the command.
* @param float $timeout Process timeout in seconds.
* @return Process The Symfony Process instance after execution.
* @throws \InvalidArgumentException If `$command` is empty or `$cwd` is not a directory.
*/
public function run(array $command, string $cwd, float $timeout = 3.0): Process
{
if ($command === []) {
throw new \InvalidArgumentException('Process command cannot be empty');
}

if (!$this->fs->isDirectory($cwd)) {
throw new \InvalidArgumentException("Invalid working directory: {$cwd}");
}

$process = new Process($command, $cwd);
$process->setTimeout($timeout);
$process->run();

return $process;
}
}
Loading
Loading