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/rules/00-main.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ The goal is for all the code in this repository to appear as if it were written

This can include everything from naming files, classes, variables, or array keys to the precedence and type of parameters passed to a function, to how logic flows and how the code is organized or commented.

**✔️ Don't worry about tests:** Write or run tests ONLY if specifically instructed
**✔️ Tests are off-limits:** Don't run or edit tests; run or edit tests ONLY if explicitly instructed to do so!

**🧠 AI Agent Protocol:** ULTRATHINK → STEP BY STEP → ACT
13 changes: 10 additions & 3 deletions .cursor/rules/01-architecture.mdc
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
globs: app/**/*.php,tests/**/*.php
alwaysApply: false
alwaysApply: true
---

## Architecture Rules
Expand Down Expand Up @@ -83,10 +82,18 @@ $service = $container->build(TestService::class);

- Services provide atomic, reusable functionality with no console I/O
- Services accept plain PHP data types and return plain PHP data types
- Services must be stateless and dependency-injected
- Services must be dependency-injected via constructor
- Services handle core business logic, external API calls, file operations
- Complex orchestration shared by multiple Commands should be extracted to dedicated Services

**Service State:**

- **Stateless Services:** Pure operations with no internal state (e.g., validators, calculators, API clients)
- **Stateful Services:** Services that manage configuration or cached data (e.g., config loaders, file managers, repositories)
- Stateful services should use lazy loading when initialization is expensive or path-dependent
- State must be initialized explicitly via public methods before use (e.g., `load()`, `initialize()`)
- Services should document their stateful nature and initialization requirements

### Console I/O Rules

- Only Commands perform console input/output operations
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/02-tests.mdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
globs: app/**/*.php,tests/**/*.php
alwaysApply: true
---

## Testing Rules
Expand Down
3 changes: 1 addition & 2 deletions .cursor/rules/03-commands.mdc
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
globs: app/Console/*.php,app/Contracts/BaseCommand.php,app/SymfonyApp.php
description: Symfony Console rules
alwaysApply: true
---

## Symfony Console Rules
Expand Down
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
.vscode/
node_modules/
vendor/
*.cache
*.log
.DS_Store
.env
.env.*
*.cache
*.log
inventory.yml
Thumbs.db
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
```
╭──────────────────────────────────────────
┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐
││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘
─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP

The Server Provisioning & Deployment Tool for PHP

Support this project on GitHub ♥ https://github.com/bigpixelrocket/deployer-php
The Server & Site Deployment Tool for PHP
╰──────────────────────────────────────────
```

[![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](https://github.com/bigpixelrocket/deployer-php)
Expand Down
2 changes: 2 additions & 0 deletions app/Console/HelloCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class HelloCommand extends BaseCommand
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
parent::execute($input, $output);

$user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there';

$this->io->success('Hello ' . $user . '!');
Expand Down
69 changes: 57 additions & 12 deletions app/Contracts/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Input\InputOption;

abstract class BaseCommand extends Command
{
Expand All @@ -26,35 +27,79 @@ public function __construct(
parent::__construct();
}

//
// Common config
// -------------------------------------------------------------------------------

/**
* Add custom env and inventory options.
*/
protected function configure(): void
{
parent::configure();

$this->addOption(
'env',
null,
InputOption::VALUE_OPTIONAL,
'Custom path to .env file (defaults to .env in the current working directory)'
);

$this->addOption(
'inventory',
null,
InputOption::VALUE_OPTIONAL,
'Custom path to inventory.yml file (defaults to inventory.yml in the current working directory)'
);
}

/**
* Initialize IO early so subclasses can use $this->io in initialize()/interact().
* Initialize IO and services early.
*/
protected function initialize(InputInterface $input, OutputInterface $output): void
{
parent::initialize($input, $output);

$this->io = new SymfonyStyle($input, $output);
$this->isQuiet = $output->isQuiet();

$envStatus = $this->env->getEnvFileStatus();
$inventoryStatus = $this->inventory->getInventoryFileStatus();
//
// Initialize env service

/** @var ?string $customEnvPath */
$customEnvPath = $input->getOption('env');
$this->env->setCustomPath($customEnvPath);
$this->env->loadEnvFile();

//
// Initialize inventory service

/** @var ?string $customInventoryPath */
$customInventoryPath = $input->getOption('inventory');
$this->inventory->setCustomPath($customInventoryPath);
$this->inventory->loadInventoryFile();
}

$this->hr();
/**
* Display env and inventory statuses.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$envStatus = $this->env->getEnvFileStatus();
$color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray';
$this->writeln([
' <fg=cyan>Environment:</> ',
' <fg=gray>'.$envStatus.'</>',
" <fg={$color}>{$envStatus}</>",
'',
]);

$inventoryStatus = $this->inventory->getInventoryFileStatus();
$this->writeln([
' <fg=cyan>Inventory:</> ',
' <fg=gray>'.$inventoryStatus.'</>',
'',
]);
}


/**
* The main execution method in Symfony commands.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
return Command::SUCCESS;
}

Expand Down
68 changes: 49 additions & 19 deletions app/Services/EnvService.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ class EnvService
/** @var array<string, string> */
private array $dotenv = [];

private ?string $envPath = null;

private string $envFileStatus = '';

public function __construct(
private readonly Filesystem $filesystem,
private readonly Dotenv $dotenvParser,
) {
$this->loadDotenvFile();
}

//
Expand Down Expand Up @@ -53,12 +54,42 @@ public function get(array|string $keys, bool $required = true): ?string
if ($required) {
$list = implode(', ', $keysList);
$label = count($keysList) > 1 ? 'variables' : 'variable';
throw new \RuntimeException("Missing environment {$label}: {$list}");
throw new \RuntimeException("Missing required environment {$label}: {$list}");
}

return null;
}

/**
* Set a custom .env path.
*/
public function setCustomPath(?string $path): void
{
$this->envPath = $path;
}

/**
* Load and parse .env file if it exists.
*/
public function loadEnvFile(): void
{
$this->dotenv = [];

$path = $this->getEnvPath();

if (!$this->filesystem->exists($path)) {
$this->envFileStatus = "No .env file found at {$path}";
return;
}

$this->readDotenv();

$this->envFileStatus = "Reading variables from {$path}";
if (!count($this->dotenv)) {
$this->envFileStatus = "No variables found in {$path}";
}
}

/**
* Get the status of the .env file.
*/
Expand All @@ -72,34 +103,33 @@ public function getEnvFileStatus(): string
// -------------------------------------------------------------------------------

/**
* Load and parse .env file if it exists.
* Get the resolved .env path (custom or default).
*/
private function loadDotenvFile(): void
private function getEnvPath(): string
{
$envPath = rtrim((string) getcwd(), '/') . '/.env';
return $this->envPath ?? rtrim((string) getcwd(), '/') . '/.env';
}

if (!$this->filesystem->exists($envPath)) {
$envDir = dirname($envPath);
$this->envFileStatus = "No .env file found at {$envDir}";
return;
}
/**
* Read .env file into internal array.
*
* @throws \RuntimeException If file cannot be read or parsed
*/
private function readDotenv(): void
{
$path = $this->getEnvPath();

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

foreach ($parsed as $k => $v) {
if (is_string($k) && is_string($v)) {
$this->dotenv[$k] = $v;
}
}

$varCount = count($this->dotenv);
$label = $varCount === 1 ? 'variable' : 'variables';
$this->envFileStatus = "Reading {$varCount} {$label} from {$envPath}";
} catch (\Throwable) {
$this->dotenv = [];
$this->envFileStatus = "Error reading .env file from {$envPath}";
} catch (\Throwable $e) {
throw new \RuntimeException("Error reading .env file from {$path}: " . $e->getMessage());
}
}
}
Loading