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
27 changes: 27 additions & 0 deletions .cursor/commands/create-branch-and-commits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged
and create a new branch with a suitable name.

Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes:
feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/.

Keep the branch name short (≤ 50 chars) yet informative. Do not push, pull, or rebase.

Examples:

- feat/parser-add-php-84-attributes
- fix/ci-matrix-php-versions
- chore/deps-bump-composer-installers-2-3

Then, create one or more commits with suitable titles.

Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful).

Keep titles short (≤ 72 chars), imperative, no trailing period. Do not push, pull, or rebase.

Examples:

- feat(parser): add support for PHP 8.4 attributes
- fix(ci): correct matrix PHP versions in build workflow
- chore(deps): bump composer/installers to ^2.3

Body (optional): explain motivation, context, and breaking changes (use BREAKING CHANGE:).
25 changes: 25 additions & 0 deletions .cursor/rules/02-tests.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@ alwaysApply: true
- `composer pest` - run entire test suite in parallel, with coverage
- `vendor/bin/pest $TEST_FILE` - run specific test file

### Dependency Injection in Tests

**The DI Container rule applies to PRODUCTION code, not tests.**

**✅ PREFERRED - Manual Instantiation (Unit Tests):**

```php
$mockFs = mockFilesystem(true, 'content');
$service = new EnvService(new FilesystemService($mockFs), new Dotenv());
```

Benefits: Clear dependency wiring, easy mock injection, no container overhead.

**✅ OPTIONAL - Container (Integration Tests):**

```php
$container = new Container();
$container->bind(Filesystem::class, fn() => $mockFs);
$service = $container->build(EnvService::class);
```

When to use: Testing multiple services together or verifying DI configuration.

**Rule:** In unit tests, prefer `new ClassName()` with explicit mocks. Save the container for integration tests.

### Test Minimalism

**Target:** Keep test files under 1.8x the size of source code they test.
Expand Down
9 changes: 4 additions & 5 deletions app/Services/EnvService.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace Bigpixelrocket\DeployerPHP\Services;

use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Filesystem\Filesystem;

/**
* Environment variable reader (first checks .env file then system environment variables)
Expand All @@ -20,7 +19,7 @@ class EnvService
private string $envFileStatus = '';

public function __construct(
private readonly Filesystem $filesystem,
private readonly FilesystemService $fs,
private readonly Dotenv $dotenvParser,
) {
}
Expand Down Expand Up @@ -77,7 +76,7 @@ public function loadEnvFile(): void

$path = $this->getEnvPath();

if (!$this->filesystem->exists($path)) {
if (!$this->fs->exists($path)) {
$this->envFileStatus = "No .env file found at {$path}";
return;
}
Expand Down Expand Up @@ -107,7 +106,7 @@ public function getEnvFileStatus(): string
*/
private function getEnvPath(): string
{
return $this->envPath ?? rtrim((string) getcwd(), '/') . '/.env';
return $this->envPath ?? rtrim($this->fs->getCwd(), '/') . '/.env';
}

/**
Expand All @@ -120,7 +119,7 @@ private function readDotenv(): void
$path = $this->getEnvPath();

try {
$content = $this->filesystem->readFile($path);
$content = $this->fs->readFile($path);
$parsed = $this->dotenvParser->parse($content, $path);

foreach ($parsed as $k => $v) {
Expand Down
106 changes: 106 additions & 0 deletions app/Services/FilesystemService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Services;

use Symfony\Component\Filesystem\Filesystem;

/**
* Thin wrapper around Symfony Filesystem with gap-filling methods.
*
* Provides a mockable interface for all filesystem operations. All services
* should use this exclusively instead of Symfony Filesystem or native PHP
* functions directly.
*
* @example
* // Symfony Filesystem wrappers
* $fs->exists('/path/to/file');
* $content = $fs->readFile('/path/to/file');
* $fs->dumpFile('/path/to/file', 'contents');
*
* // Gap-filling methods (native PHP functions wrapped)
* $cwd = $fs->getCwd();
* $isDir = $fs->isDirectory('/path');
* $parent = $fs->getParentDirectory(__DIR__, 2);
*/
final readonly class FilesystemService
{
public function __construct(
private Filesystem $fs,
) {
}

//
// Symfony Filesystem Wrappers
// -------------------------------------------------------------------------------

/**
* Check if a file or directory exists.
*/
public function exists(string $path): bool
{
return $this->fs->exists($path);
}

/**
* Read file contents.
*
* @throws \RuntimeException If file cannot be read
*/
public function readFile(string $path): string
{
return $this->fs->readFile($path);
}

/**
* Write contents to a file.
*
* @throws \RuntimeException If file cannot be written
*/
public function dumpFile(string $path, string $content): void
{
$this->fs->dumpFile($path, $content);
}

//
// Gap-Filling Methods (Native PHP Functions)
// -------------------------------------------------------------------------------

/**
* Get current working directory.
*
* @throws \RuntimeException If current directory cannot be determined
*/
public function getCwd(): string
{
$cwd = getcwd();
if ($cwd === false) {
throw new \RuntimeException('Unable to determine current working directory');
}

return $cwd;
}

/**
* Check if path is a directory.
*/
public function isDirectory(string $path): bool
{
return $this->exists($path) && is_dir($path);
}

/**
* Get parent directory path.
*
* @param int $levels Number of parent directories to traverse (default: 1)
*/
public function getParentDirectory(string $path, int $levels = 1): string
{
if ($levels < 1) {
throw new \InvalidArgumentException('Levels must be at least 1');
}

return dirname($path, $levels);
}
}
11 changes: 5 additions & 6 deletions app/Services/InventoryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Bigpixelrocket\DeployerPHP\Services;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;

/**
Expand Down Expand Up @@ -41,7 +40,7 @@ class InventoryService
private ?string $inventoryFileStatus = null;

public function __construct(
private readonly Filesystem $filesystem,
private readonly FilesystemService $fs,
) {
}

Expand Down Expand Up @@ -102,7 +101,7 @@ public function loadInventoryFile(): void
$path = $this->getInventoryPath();

// Initialize empty inventory file if it doesn't exist
if (!$this->filesystem->exists($path)) {
if (!$this->fs->exists($path)) {
$this->inventoryFileStatus = "Creating inventory file at {$path}";
$this->writeInventory();
}
Expand Down Expand Up @@ -220,7 +219,7 @@ private function unsetByPath(array &$data, array $segments): bool
*/
private function getInventoryPath(): string
{
return $this->inventoryPath ?? rtrim((string) getcwd(), '/') . '/inventory.yml';
return $this->inventoryPath ?? rtrim($this->fs->getCwd(), '/') . '/inventory.yml';
}

/**
Expand All @@ -233,7 +232,7 @@ private function readInventory(): void
$path = $this->getInventoryPath();

try {
$raw = $this->filesystem->readFile($path);
$raw = $this->fs->readFile($path);
$parsed = Yaml::parse($raw);

/** @var array<string, mixed> $inventory */
Expand All @@ -257,7 +256,7 @@ private function writeInventory(): void

try {
$yaml = Yaml::dump($this->inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
$this->filesystem->dumpFile($path, $yaml);
$this->fs->dumpFile($path, $yaml);
} catch (\Throwable $e) {
throw new \RuntimeException("Error writing inventory file at {$path}: " . $e->getMessage());
}
Expand Down
9 changes: 7 additions & 2 deletions app/Services/ProcessFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
/**
* Factory for creating Process instances with consistent timeout configuration.
*/
final class ProcessFactory
final readonly class ProcessFactory
{
public function __construct(
private FilesystemService $fs,
) {
}

/**
* Create a new Process with timeout.
*
Expand All @@ -22,7 +27,7 @@ public function create(array $command, string $cwd, ?float $timeout = 3.0): Proc
throw new \InvalidArgumentException('Process command cannot be empty');
}

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

Expand Down
19 changes: 9 additions & 10 deletions app/Services/SSHService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2;
use Symfony\Component\Filesystem\Filesystem;

/**
* SSH and SFTP operations for remote server management.
Expand Down Expand Up @@ -42,7 +41,7 @@ class SSHService
{
public function __construct(
private readonly EnvService $envService,
private readonly Filesystem $filesystem,
private readonly FilesystemService $fs,
) {
}

Expand Down Expand Up @@ -96,11 +95,11 @@ public function executeCommand(string $host, int $port, string $username, string
*/
public function executeScript(string $host, int $port, string $username, string $scriptPath, ?string $privateKeyPath = null): array
{
if (!$this->filesystem->exists($scriptPath)) {
if (!$this->fs->exists($scriptPath)) {
throw new \RuntimeException("Script file does not exist: {$scriptPath}");
}

$scriptContents = $this->filesystem->readFile($scriptPath);
$scriptContents = $this->fs->readFile($scriptPath);

$ssh = $this->createConnection($host, $port, $username, $privateKeyPath);

Expand Down Expand Up @@ -128,14 +127,14 @@ public function executeScript(string $host, int $port, string $username, string
*/
public function uploadFile(string $host, int $port, string $username, string $localPath, string $remotePath, ?string $privateKeyPath = null): void
{
if (!$this->filesystem->exists($localPath)) {
if (!$this->fs->exists($localPath)) {
throw new \RuntimeException("Local file does not exist: {$localPath}");
}

$sftp = $this->createSFTPConnection($host, $port, $username, $privateKeyPath);

try {
$contents = $this->filesystem->readFile($localPath);
$contents = $this->fs->readFile($localPath);

$uploaded = $sftp->put($remotePath, $contents);
if (!$uploaded) {
Expand Down Expand Up @@ -163,7 +162,7 @@ public function downloadFile(string $host, int $port, string $username, string $
throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}");
}

$this->filesystem->dumpFile($localPath, is_string($contents) ? $contents : '');
$this->fs->dumpFile($localPath, is_string($contents) ? $contents : '');
} catch (\Throwable $e) {
throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}: " . $e->getMessage());
} finally {
Expand Down Expand Up @@ -260,11 +259,11 @@ private function loadPrivateKey(?string $privateKeyPath): PrivateKey
throw new \RuntimeException('No SSH private key found. Provide a key path or place a key at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa');
}

if (!$this->filesystem->exists($resolvedKeyPath)) {
if (!$this->fs->exists($resolvedKeyPath)) {
throw new \RuntimeException("SSH key does not exist: {$resolvedKeyPath}");
}

$keyContents = $this->filesystem->readFile($resolvedKeyPath);
$keyContents = $this->fs->readFile($resolvedKeyPath);

try {
$key = PublicKeyLoader::load($keyContents);
Expand Down Expand Up @@ -306,7 +305,7 @@ private function resolvePrivateKeyPath(?string $path): ?string

// Return first existing candidate
foreach ($candidates as $candidate) {
if ($this->filesystem->exists($candidate)) {
if ($this->fs->exists($candidate)) {
return $candidate;
}
}
Expand Down
5 changes: 3 additions & 2 deletions app/Services/VersionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class VersionService
{
public function __construct(
private readonly ProcessFactory $processFactory,
private readonly FilesystemService $fs,
private readonly string $packageName = 'bigpixelrocket/deployer-php',
private readonly string $fallbackVersion = 'dev-main'
) {
Expand Down Expand Up @@ -72,7 +73,7 @@ public function getVersionFromComposer(): ?string
*/
public function getVersionFromGit(?string $projectRoot = null): ?string
{
$projectRoot ??= dirname(__DIR__, 2);
$projectRoot ??= $this->fs->getParentDirectory(__DIR__, 2);

// Check if we're in a git repository
if (!$this->isGitRepository($projectRoot)) {
Expand Down Expand Up @@ -104,7 +105,7 @@ public function getVersionFromGit(?string $projectRoot = null): ?string
*/
public function isGitRepository(string $projectRoot): bool
{
return is_dir($projectRoot . '/.git');
return $this->fs->isDirectory($projectRoot . '/.git');
}

/**
Expand Down
Loading