diff --git a/.cursor/cli.json b/.cursor/cli.json new file mode 100644 index 00000000..aa2a1431 --- /dev/null +++ b/.cursor/cli.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": [], + "deny": ["Shell(git push)", "Shell(gh pr create)", "Write(**)"] + } +} diff --git a/.cursor/commands/review-testing-rules.md b/.cursor/commands/review-testing-rules.md new file mode 100644 index 00000000..2205e791 --- /dev/null +++ b/.cursor/commands/review-testing-rules.md @@ -0,0 +1 @@ +Analyze the changes in this Git working tree and report back on tests that fall short of our testing rules. diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index eeb85f4e..457d2a7b 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -4,12 +4,23 @@ globs: app/**/*.php,templates/**/*.yaml ### Architecture (MANDATORY) -- **PHP:** PSR-12, strict types, PHP 8.x features (unions, match, attributes, readonly); -- **Imports:** Always add `use` statements instead of fully qualified class names; -- **Type Safety:** All methods must have explicit return types with proper generics (e.g., `Collection`). +- **PHP:** PSR-12, strict types, PHP 8.x features (unions, match, attributes, readonly) +- **Imports:** Always add `use` statements instead of fully qualified class names +- **Type Safety:** All methods must have explicit return types with proper generics (e.g., `Collection`) **🚨 Architecture rules are IMMUTABLE - fix violating code, not the architecture rules** +### Symfony Patterns (MANDATORY) + +- **Dependency Injection:** instead of manually resolving and instantiating classes +- **Symfony Classes:** instead of native PHP functions for easier mocking during testing (eg. `Filesystem::`, `Process::`, etc.) + +### Dependency Injection (MANDATORY) + +- Dependencies are automatically injected by the `app/Container` class +- ALL dependencies MUST be injected through constructors - NO manual instantiation +- **Exceptions:** Only value objects, DTOs, and pure data structures can be manually instantiated + ### Command Layer - Commands handle user interaction (input/output) and orchestrate Services @@ -26,31 +37,17 @@ globs: app/**/*.php,templates/**/*.yaml - Services handle core business logic, external API calls, file operations - Complex orchestration shared by multiple Commands should be extracted to dedicated Services -### Shared Utilities - -- Common path resolution, configuration loading goes in dedicated utility classes -- Utility classes are static or singleton patterns for simple operations -- No business logic or state in utilities - pure functions only - ### Console I/O Rules - Only Commands perform console input/output operations -- Services return exceptions or structured data for Commands to handle - Use SymfonyStyle consistently for all user-facing output +- Services return exceptions or structured data for Commands to handle - Validation errors and business exceptions bubble up to Commands for display -### Dependency Injection (Mandatory) - -- ALL dependencies MUST be injected through constructors - NO manual instantiation -- Commands receive Services via constructor injection -- Services receive other Services/utilities via constructor injection -- Use ServiceContainer or dependency injection container for all object creation -- NEVER use `new ClassName()` inside methods - always inject dependencies -- Exceptions: Only value objects, DTOs, and pure data structures can be manually instantiated - ### Dependency Rules - Commands depend on Services - Services depend on other Services or utilities -- No circular dependencies allowed - All dependencies declared in constructor signatures + +**IMPORTANT:** No circular dependencies allowed. diff --git a/.cursor/rules/03-tests.mdc b/.cursor/rules/03-tests.mdc index a5747e8d..dff36d50 100644 --- a/.cursor/rules/03-tests.mdc +++ b/.cursor/rules/03-tests.mdc @@ -10,212 +10,93 @@ globs: app/**/*.php,tests/**/*.php ### Running Tests -- `composer pest` run the entire test suite in parallel, with coverage -- `vendor/bin/pest $TEST_FILE` run a specific test file +- `composer pest` - run entire test suite in parallel, with coverage +- `vendor/bin/pest $TEST_FILE` - run specific test file -### Test Minimalism Rules +### Test Minimalism -**Write only essential tests that would break if business logic fails:** +**Target:** Keep test files under 1.8x the size of source code they test. -- **Core Business Logic Only:** Test critical paths, skip framework testing -- **Minimal Test Data:** Use simplest possible setup, avoid complex scenarios -- **Standard Assertions:** Prefer built-in `expect()` over custom assertions -- **Essential Edge Cases:** Only test failure modes that actually matter -- **No Performance Tests:** Unless performance is the primary concern +**Rules:** -### Static Analysis in Tests +- Test core business logic only, skip framework testing +- Use dataset-driven testing: `->with([])` for multiple scenarios +- Eliminate test overlap: no two tests covering same functionality +- Consolidate assertions: `expect($x)->toBe(1)->and($y)->toBe(2)` +- Mock external dependencies only +- No performance tests unless performance is the primary concern +- Don't sacrifice readability for ratio targets -- Ignore PHPStan issues in tests. -- Avoid excessive phpdoc just to appease types; add only when necessary. +**Don't consolidate when:** -### AAA Pattern Requirements (MANDATORY) +- Different public methods +- Exception vs normal flow tests +- Different setup requirements +- Distinct business logic -**All tests MUST follow the AAA pattern with explicit section headers:** +### AAA Pattern (MANDATORY) ```php it('does something specific', function () { // ARRANGE - $testData = ['key' => 'value']; - $mockService = mock(SomeService::class); + $service = new Service(mock(Dependency::class)); // ACT - $result = $this->service->performAction($testData); + $result = $service->performAction(); // ASSERT - expect($result)->toBe($expectedValue); - $mockService->shouldHaveReceived('method'); + expect($result)->toBe('expected'); // CLEANUP (when needed) $this->resetTimeState(); - $this->cleanupTempFiles(); + unlink($tempFile); }); ``` -**Required Section Headers:** +**Exception tests:** Use `// ACT & ASSERT` when act triggers assertion. -- `// ARRANGE` - Setup test data, mocks, and dependencies -- `// ACT` - Execute the code under test (single action) -- `// ASSERT` - Verify expected outcomes and behaviors -- `// CLEANUP` - Reset state when necessary (time state, temp files, etc.) +**Organization:** Use `describe()` blocks, `beforeEach()` setup, extract helpers/traits for DRY tests. -**Exception Pattern:** +### Testing Patterns -- `// ACT & ASSERT` - For exception tests where the act triggers the assertion - -### Organizing Tests & Helpers - -**DRY Principle:** Tests should be as DRY and streamlined as the code they're testing. - -**Best Practices:** - -- Logical grouping with `describe()` blocks -- Extract repeated mocking into reusable helper methods -- Create test traits for shared behavior across test classes -- Use `beforeEach()` blocks for common setup within test groups -- Use proper cleanup in tests (temp files, reset state) for test isolation -- Build helper functions for creating test configurations and mock data - -#### Test Helpers - -- Evolution-friendly, loosely coupled -- Support interface changes - -**Helper Examples:** +**❌ FORBIDDEN:** ```php -// Test trait for common mocking -trait MocksExternalServices -{ - protected function mockSuccessfulProcess(): void - { - // Mock external process calls - $this->processRunner = mock(ProcessRunner::class); - $this->processRunner->shouldReceive('run') - ->andReturn(['output' => 'Success', 'exitCode' => 0]); - } -} - -// Reusable test data builders -function createCommandOptions(array $overrides = []): array -{ - return array_merge([ - 'composer' => true, - 'npm' => false, - 'force' => false, - ], $overrides); -} - -// beforeEach for common setup -describe('package installation', function () { - beforeEach(function () { - $this->mockSuccessfulProcess(); - $this->baseOptions = createCommandOptions(); - }); -}); -``` - -**Benefits:** - -- Reduces test maintenance burden -- Ensures consistent mocking patterns -- Makes tests more readable and focused -- Easier to update when dependencies change - -### FORBIDDEN Patterns (Auto-Reject) - -```php -expect($x)->toBeInstanceOf(Class::class); // Type-only -expect($x)->toBeArray(); // Generic +expect($x)->toBeInstanceOf(Class::class); // Type-only testing +expect($x)->toBeArray(); // Generic assertions expect($x)->not->toBeNull(); // Meaningless -expect($x)->toBeTrue(); // No context -expect(true)->toBeTrue(); // This is literally meaningless -expect($object->property())->toBeInstanceOf(); // Property type testing -sleep(...); // Use proper time mocking instead +expect(true)->toBeTrue(); // Literally meaningless +sleep(...); // Use time mocking ``` -### REQUIRED Patterns +**✅ REQUIRED:** ```php -// Test specific values and behavior -expect($config->getValue('host'))->toBe('example.com') - ->and($config->getValue('port'))->toBe(22); - -// Test with datasets -it('validates server hostnames', function (string $hostname, bool $valid) { - expect($this->validator->isValidHostname($hostname))->toBe($valid); -})->with([ - ['server.example.com', true], - ['invalid_hostname!', false], -]); - -// Mock only external dependencies -$processRunner = mock(ProcessRunner::class); -$processRunner->shouldReceive('run')->with('ls *')->andReturn('file1.txt'); - -$httpClient = mock(HttpClient::class); -$httpClient->shouldReceive('get')->with('api/endpoint')->andReturn(['status' => 'ok']); +expect($config->getValue('host'))->toBe('example.com'); +expect($this->validator->isValid($input))->toBe($expected); +$mock->shouldReceive('method')->with('param')->andReturn('result'); ``` -### Unit Test Isolation - -**Core Principle:** True unit tests must be isolated from external dependencies. +### Test Types -**Unit tests should:** +**Unit Tests:** -- Test single units of code in isolation -- Use mocks/fakes for all external dependencies -- Run without external dependencies +- Mock all external dependencies (filesystem, HTTP, processes) +- Test single units in isolation - Complete in milliseconds -**Unit tests MUST NOT:** +**Integration Tests:** -- Use real file system, network calls, or shell commands -- Test service integrations with external systems -- Make actual HTTP requests or process executions -- Depend on external services, APIs, or system processes +- Real file operations and external processes +- CLI commands and full workflows -**Integration vs Unit:** Use integration tests for file system operations and external processes, unit tests (with mocks) for pure business logic. +**Layer Strategy:** -### Layer Testing Strategy +- CLI Commands → Integration tests +- Business Services → Unit tests (mocked dependencies) +- Utilities/Helpers → Unit tests -- **CLI Commands:** Integration tests, mock external processes and services -- **Business Services:** Unit tests, mock all external calls and file operations -- **Utilities/Helpers:** Unit tests with isolated scenarios +### Static Analysis -```php -// ✅ Service unit test (mocked dependencies) -$this->fileSystem->shouldReceive('exists')->andReturn(true); -$this->processRunner->shouldReceive('execute')->andReturn(['output' => 'success']); - -// ✅ Command integration test (real file operations) -$testFile = '/tmp/test-config.yml'; -file_put_contents($testFile, 'host: example.com'); -$result = $this->command->execute(['--config' => $testFile]); -expect($result->exitCode)->toBe(0); -unlink($testFile); // cleanup -``` - -### Performance Testing - -**When testing CLI operations:** - -- Monitor execution time for file operations and external processes -- Test with realistic server counts and configuration sizes to expose performance issues -- Verify efficient processing of multiple servers or long-running deployment operations - -```php -// ✅ CLI performance monitoring pattern -it('processes server provisioning efficiently', function () { - // ARRANGE - $serverConfigs = createTestServerConfigs(5); - - // ACT - $startTime = microtime(true); - $result = $this->provisioningService->processServers($serverConfigs); - $executionTime = microtime(true) - $startTime; - - // ASSERT - expect($result->getSuccessCount())->toBe(5) - ->and($executionTime)->toBeLessThan(2.0); // Should complete provisioning in under 2 seconds -}); -``` +- Ignore PHPStan issues in tests - focus on test functionality over compliance +- Avoid excessive phpdoc just to appease types diff --git a/.github/workflows/testing-rules.yml b/.github/workflows/testing-rules.yml new file mode 100644 index 00000000..ac156c71 --- /dev/null +++ b/.github/workflows/testing-rules.yml @@ -0,0 +1,58 @@ +name: Testing Rules + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + +jobs: + testing-rules: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Cursor CLI + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.cursor/bin" >> $GITHUB_PATH + + - name: Testing rules review + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + GH_TOKEN: ${{ github.token }} + MODEL: 'claude-4-sonnet' + run: | + cursor-agent --force --model "$MODEL" --output-format=text --print "You are operating in a GitHub Actions runner performing automated code review. The gh CLI is available and authenticated via GH_TOKEN. You may comment on pull requests. + + Context: + - Repo: ${{ github.repository }} + - PR Number: ${{ github.event.pull_request.number }} + - PR Head SHA: ${{ github.event.pull_request.head.sha }} + - PR Base SHA: ${{ github.event.pull_request.base.sha }} + + Review the current PR diff and... + 1. report back on tests that fall short of our testing rules + 2. identify gaps in our tests or missing tests + 3. leave very short inline comments (1-2 sentences) on suggested changes and a brief summary at the end + + Procedure: + - Get existing comments: gh pr view --json comments + - Get diff: gh pr diff + - If a previously reported issue appears fixed by nearby changes, reply: ✅ This issue appears to be resolved by the recent changes + - Avoid duplicates: skip if similar feedback already exists on or near the same lines + + Commenting rules: + - Natural tone, specific and actionable; do not mention automated or high-confidence + - Use emojis: 🚨 Critical 🔒 Security ⚡ Performance ⚠️ Logic ✅ Resolved ✨ Improvement + + Submission: + - Submit one review containing inline comments plus a concise summary + - Use only: gh pr review --comment + - Do not use: gh pr review --approve or --request-changes" diff --git a/app/Console/Server/ServerCreateCommand.php b/app/Console/Server/ServerCreateCommand.php new file mode 100644 index 00000000..cb6c4076 --- /dev/null +++ b/app/Console/Server/ServerCreateCommand.php @@ -0,0 +1,79 @@ +addArgument('name', InputArgument::REQUIRED, 'Server name (unique identifier)') + ->addArgument('host', InputArgument::REQUIRED, 'Server host (IP or FQDN)') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'SSH port', '22') + ->addOption('user', null, InputOption::VALUE_REQUIRED, 'SSH username', 'root') + ->addOption('key', null, InputOption::VALUE_OPTIONAL, 'Path to SSH private key (default: ~/.ssh/id_rsa)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $name */ + $name = $input->getArgument('name'); + + /** @var string $host */ + $host = $input->getArgument('host'); + + /** @var string|int $portOption */ + $portOption = $input->getOption('port'); + $port = (int) $portOption; + + /** @var string $user */ + $user = $input->getOption('user'); + + /** @var string|null $keyPath */ + $keyPath = $input->getOption('key'); + $keyPath = $keyPath !== null && $keyPath !== '' ? $keyPath : null; + + try { + $io->section('Verifying SSH connectivity'); + $io->writeln(sprintf('Connecting to %s@%s:%d', $user, $host, $port)); + + $this->sshService->assertCanConnect($host, $port, $user, $keyPath); + + $io->success('SSH connectivity verified.'); + + $dto = new ServerDTO(host: $host, port: $port, user: $user, key: $keyPath); + $this->servers->create($name, $dto); + + $io->success(sprintf("Server '%s' saved to .deployer/inventory.yml", $name)); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/app/Container.php b/app/Container.php new file mode 100644 index 00000000..ca74a36d --- /dev/null +++ b/app/Container.php @@ -0,0 +1,154 @@ +build(MyService::class); + * ``` + */ +class Container +{ + /** @var array Currently resolving classes (circular dependency detection) */ + private array $resolving = []; + + /** @var array, constructor: ?\ReflectionMethod, parameters: ReflectionParameter[]}> Reflection data cache */ + private array $reflectionCache = []; + + /** + * Build a class instance using reflection and auto-wire dependencies. + * + * @template T of object + * @param class-string $className + * @return T + */ + public function build(string $className): object + { + // Circular dependency detection + if (isset($this->resolving[$className])) { + $chain = implode(' -> ', array_keys($this->resolving)) . " -> {$className}"; + throw new \RuntimeException("Circular dependency detected: {$chain}"); + } + + if (!class_exists($className)) { + throw new \RuntimeException("Class [{$className}] does not exist"); + } + + // Get cached reflection data + $reflectionData = $this->getReflectionData($className); + $reflector = $reflectionData['reflector']; + $constructor = $reflectionData['constructor']; + $parameters = $reflectionData['parameters']; + + if (!$reflector->isInstantiable()) { + throw new \RuntimeException("Class [{$className}] is not instantiable"); + } + + // Mark as currently resolving + $this->resolving[$className] = true; + + try { + // If no constructor, return new instance + if ($constructor === null) { + /** @var T */ + return $reflector->newInstance(); + } + + // Resolve constructor dependencies + $dependencies = $this->resolveDependencies($parameters); + + /** @var T */ + return $reflector->newInstanceArgs($dependencies); + } finally { + // Always clean up resolving state + unset($this->resolving[$className]); + } + } + + /** + * Get cached reflection data for a class. + * + * @param class-string $className + * @return array{reflector: ReflectionClass, constructor: ?\ReflectionMethod, parameters: ReflectionParameter[]} + */ + private function getReflectionData(string $className): array + { + if (!isset($this->reflectionCache[$className])) { + $reflector = new ReflectionClass($className); + $constructor = $reflector->getConstructor(); + $parameters = $constructor?->getParameters() ?? []; + + $this->reflectionCache[$className] = [ + 'reflector' => $reflector, + 'constructor' => $constructor, + 'parameters' => $parameters, + ]; + } + + return $this->reflectionCache[$className]; + } + + /** + * Resolve all dependencies for constructor parameters. + * + * @param ReflectionParameter[] $parameters + * @return array + */ + private function resolveDependencies(array $parameters): array + { + $dependencies = []; + + foreach ($parameters as $parameter) { + $dependencies[] = $this->resolveParameter($parameter); + } + + return $dependencies; + } + + /** + * Resolve a single constructor parameter. + */ + private function resolveParameter(ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + // Handle union types and built-in types + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + throw new \RuntimeException( + "Cannot resolve parameter [{$parameter->getName()}] in class [{$parameter->getDeclaringClass()?->getName()}]" + ); + } + + $className = $type->getName(); + + try { + /** @var class-string $className */ + return $this->build($className); + } catch (\RuntimeException $e) { + // If dependency resolution fails and parameter has default, use it + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + throw new \RuntimeException( + "Cannot resolve dependency [{$className}] for parameter [{$parameter->getName()}]", + previous: $e + ); + } + } +} diff --git a/app/DTOs/ServerDTO.php b/app/DTOs/ServerDTO.php new file mode 100644 index 00000000..d41514c6 --- /dev/null +++ b/app/DTOs/ServerDTO.php @@ -0,0 +1,34 @@ + $this->host, + 'port' => $this->port, + 'user' => $this->user, + 'key' => $this->key, + ]; + } +} diff --git a/app/Deployer.php b/app/Deployer.php index 87b65aa2..c4469e3f 100644 --- a/app/Deployer.php +++ b/app/Deployer.php @@ -5,13 +5,16 @@ namespace Bigpixelrocket\DeployerPHP; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerCreateCommand; class Deployer extends Application { private SymfonyStyle $io; + private Container $container; public function __construct() { @@ -20,6 +23,10 @@ public function __construct() parent::__construct('Deployer', $version); $this->setDefaultCommand('list'); + $this->container = new Container(); + + // Register commands + $this->registerCommands(); } /** @@ -69,6 +76,23 @@ private function displayBanner(): void } + // + // Command registration / DI wiring + // ------------------------------------------------------------------------------- + + private function registerCommands(): void + { + $commands = [ + ServerCreateCommand::class, + ]; + + foreach ($commands as $command) { + /** @var Command $commandInstance */ + $commandInstance = $this->container->build($command); + $this->add($commandInstance); + } + } + // // Version functions // ------------------------------------------------------------------------------- diff --git a/app/Items/ServerItem.php b/app/Items/ServerItem.php new file mode 100644 index 00000000..1f8ed0ee --- /dev/null +++ b/app/Items/ServerItem.php @@ -0,0 +1,67 @@ +assertValidName($name); + $this->assertValidServer($server); + + if ($this->exists($name)) { + throw new \RuntimeException("Server '{$name}' already exists."); + } + + $this->inventory->set('servers', $name, $server->toArray()); + } + + /** + * Check if a server exists. + */ + public function exists(string $name): bool + { + return $this->inventory->has('servers', $name); + } + + /** + * Validate server payload shape and values. + */ + private function assertValidServer(ServerDTO $server): void + { + if ($server->host === '') { + throw new \InvalidArgumentException('Invalid host.'); + } + + $port = $server->port; + if ($port < 1 || $port > 65535) { + throw new \InvalidArgumentException('Invalid port.'); + } + + if ($server->user === '') { + throw new \InvalidArgumentException('Invalid user.'); + } + } + + private function assertValidName(string $name): void + { + if ($name === '' || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) { + throw new \InvalidArgumentException('Invalid server name. Use letters, numbers, dots, dashes, underscores.'); + } + } +} diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php index d5a5ef80..ca6183dc 100644 --- a/app/Services/EnvService.php +++ b/app/Services/EnvService.php @@ -5,25 +5,43 @@ namespace Bigpixelrocket\DeployerPHP\Services; use Symfony\Component\Dotenv\Dotenv; +use Symfony\Component\Filesystem\Filesystem; /** * Environment variable reader with fallback to .env file if value not found in environment variables. + * Uses Symfony Filesystem component for better testability and mocking capabilities. */ class EnvService { /** @var array */ private array $dotenv = []; - public function __construct() + public function __construct( + private readonly Filesystem $filesystem = new Filesystem(), + private readonly string $envPath = '.env' + ) { + $this->loadDotenvFile(); + } + + /** + * Load and parse the .env file if it exists and is readable. + */ + private function loadDotenvFile(): void { - $path = getcwd().'/.env'; - if (is_file($path) && is_readable($path)) { - $dotenv = new Dotenv(); - $parsed = $dotenv->parse((string) file_get_contents($path), $path); - foreach ($parsed as $k => $v) { - if (is_string($k) && is_string($v)) { - $this->dotenv[$k] = $v; + if ($this->filesystem->exists($this->envPath)) { + try { + $content = $this->filesystem->readFile($this->envPath); + $dotenv = new Dotenv(); + $parsed = $dotenv->parse($content, $this->envPath); + + foreach ($parsed as $k => $v) { + if (is_string($k) && is_string($v)) { + $this->dotenv[$k] = $v; + } } + } catch (\Throwable) { + // Silently ignore file reading errors, similar to original behavior + $this->dotenv = []; } } } diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..4f2de421 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,166 @@ + + */ + public function getAll(): array + { + return $this->readInventory(); + } + + /** + * Get all records from a collection. + * + * @return array + */ + /** + * @return array + */ + public function list(string $collection): array + { + $inventory = $this->readInventory(); + + $records = $inventory[$collection] ?? []; + /** @var array $safe */ + $safe = is_array($records) ? $records : []; + return $safe; + } + + /** + * Check if a key exists in a collection. + */ + public function has(string $collection, string $key): bool + { + $inventory = $this->readInventory(); + + return isset($inventory[$collection]) + && is_array($inventory[$collection]) + && array_key_exists($key, $inventory[$collection]); + } + + /** + * Get a single record from a collection. + */ + public function get(string $collection, string $key): mixed + { + $inventory = $this->readInventory(); + if (!isset($inventory[$collection]) || !is_array($inventory[$collection]) || !array_key_exists($key, $inventory[$collection])) { + throw new \RuntimeException("Key '{$key}' not found in collection '{$collection}'."); + } + + return $inventory[$collection][$key]; + } + + /** + * Set a single record in a collection (create or overwrite). + */ + public function set(string $collection, string $key, mixed $value): void + { + $inventory = $this->readInventory(); + if (!isset($inventory[$collection]) || !is_array($inventory[$collection])) { + $inventory[$collection] = []; + } + + $inventory[$collection][$key] = $value; + $this->writeInventory($inventory); + } + + /** + * Replace an entire collection. + * + * @param array $items + */ + public function setCollection(string $collection, array $items): void + { + $inventory = $this->readInventory(); + $inventory[$collection] = $items; + $this->writeInventory($inventory); + } + + /** + * Delete a single record from a collection. + */ + public function delete(string $collection, string $key, bool $mustExist = true): void + { + $inventory = $this->readInventory(); + + if (!isset($inventory[$collection]) || !is_array($inventory[$collection]) || !array_key_exists($key, $inventory[$collection])) { + if ($mustExist) { + throw new \RuntimeException("Key '{$key}' not found in collection '{$collection}'."); + } + + return; + } + + unset($inventory[$collection][$key]); + $this->writeInventory($inventory); + } + + /** + * Read inventory YAML into a structured array. + * + * @return array + */ + private function readInventory(): array + { + $path = $this->getInventoryPath(); + + if (!is_file($path)) { + return []; + } + + $raw = (string) file_get_contents($path); + $parsed = Yaml::parse($raw); + + /** @var array $result */ + $result = is_array($parsed) ? $parsed : []; + return $result; + } + + /** + * Persist inventory data to YAML file. + * + * @param array $inventory + */ + private function writeInventory(array $inventory): void + { + $dir = $this->getInventoryDir(); + if (!is_dir($dir)) { + if (!@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new \RuntimeException("Unable to create inventory directory: {$dir}"); + } + } + + $path = $this->getInventoryPath(); + $yaml = Yaml::dump($inventory, 4, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $bytes = @file_put_contents($path, $yaml); + if ($bytes === false) { + throw new \RuntimeException("Failed to write inventory file at {$path}"); + } + } + + private function getInventoryDir(): string + { + return rtrim((string) getcwd(), '/').'/.deployer'; + } + + private function getInventoryPath(): string + { + return $this->getInventoryDir().'/inventory.yml'; + } +} diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php new file mode 100644 index 00000000..1696e08c --- /dev/null +++ b/app/Services/SSHService.php @@ -0,0 +1,118 @@ +resolvePrivateKeyPath($privateKeyPath); + + if ($resolvedKeyPath === null) { + throw new \RuntimeException('No SSH private key found. Provide --key or place a key at ~/.ssh/id_rsa (or ~/.ssh/id_ed25519).'); + } + + if (!is_file($resolvedKeyPath) || !is_readable($resolvedKeyPath)) { + throw new \RuntimeException("SSH key is not readable: {$resolvedKeyPath}"); + } + + $keyContents = (string) file_get_contents($resolvedKeyPath); + + try { + $key = PublicKeyLoader::load($keyContents); + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to load SSH private key: '.$e->getMessage(), previous: $e); + } + + if (!$key instanceof PrivateKey) { + throw new \RuntimeException('Provided key is not a valid private key.'); + } + + try { + $ssh = new SSH2($host, $port); + } catch (\Throwable $e) { + throw new \RuntimeException("Failed to initiate SSH connection to {$host}: {$e->getMessage()}", previous: $e); + } + + try { + $loggedIn = $ssh->login($username, $key); + } catch (\Throwable $e) { + throw new \RuntimeException('SSH authentication error: '.$e->getMessage(), previous: $e); + } + + if ($loggedIn !== true) { + throw new \RuntimeException('SSH authentication failed. Check username and key permissions.'); + } + + // Best-effort disconnect; ignore errors + try { + $ssh->disconnect(); + } catch (\Throwable) { + // no-op + } + } + + /** + * Resolve a usable private key path. Preference order: + * 1) Provided path (supports ~ expansion) + * 2) ~/.ssh/id_ed25519 + * 3) ~/.ssh/id_rsa + */ + private function resolvePrivateKeyPath(?string $path): ?string + { + $candidates = []; + + if (is_string($path) && $path !== '') { + $candidates[] = $this->expandHomePath($path); + } + + $home = rtrim((string) getenv('HOME'), '/'); + if ($home !== '') { + // Default to id_rsa first, then try id_ed25519 + $candidates[] = $home.'/.ssh/id_rsa'; + $candidates[] = $home.'/.ssh/id_ed25519'; + } + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * Expand a leading tilde in a filesystem path to the user's HOME. + */ + private function expandHomePath(string $path): string + { + if ($path === '' || $path[0] !== '~') { + return $path; + } + + $home = (string) getenv('HOME'); + if ($home === '') { + return $path; // best effort + } + + return $home.substr($path, 1); + } +} diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php new file mode 100644 index 00000000..80f10f2f --- /dev/null +++ b/tests/Unit/ContainerTest.php @@ -0,0 +1,166 @@ +service; + } +} + +class ServiceWithMultipleDeps +{ + public function __construct(private readonly SimpleService $s1, private readonly ServiceWithDependency $s2) + { + } + public function getSimple(): SimpleService + { + return $this->s1; + } + public function getComplex(): ServiceWithDependency + { + return $this->s2; + } +} + +class ServiceWithDefaults +{ + public function __construct(private readonly SimpleService $service, private readonly string $name = 'default') + { + } + public function getName(): string + { + return $this->name; + } +} + +class CircularA +{ + public function __construct(private readonly CircularB $b) + { + } +} + +class CircularB +{ + public function __construct(private readonly CircularA $a) + { + } +} + +class ServiceWithScalarParam +{ + public function __construct(private readonly string $required) + { + } +} + +interface TestInterface +{ +} + +abstract class AbstractClass +{ +} + +class PrivateConstructor +{ + private function __construct() + { + } +} + + +describe('Container', function () { + beforeEach(function () { + $this->container = new Container(); + }); + + it('builds classes without dependencies', function () { + // ARRANGE & ACT + $simple = $this->container->build(SimpleService::class); + $noConstructor = $this->container->build(NoConstructorService::class); + + // ASSERT + expect($simple->getName())->toBe('simple') + ->and($noConstructor->getType())->toBe('no-constructor') + ->and($this->container->build(SimpleService::class))->not->toBe($simple); // New instances + }); + + it('resolves dependencies recursively', function () { + // ARRANGE & ACT + $service = $this->container->build(ServiceWithMultipleDeps::class); + + // ASSERT + expect($service->getSimple()->getName())->toBe('simple') + ->and($service->getComplex()->getDependency()->getName())->toBe('simple'); + }); + + it('uses default parameter values', function () { + // ARRANGE & ACT + $service = $this->container->build(ServiceWithDefaults::class); + + // ASSERT + expect($service->getName())->toBe('default'); + }); + + it('detects circular dependencies', function () { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->container->build(CircularA::class)) + ->toThrow(RuntimeException::class, 'Cannot resolve dependency'); + }); + + it('throws exceptions for invalid classes', function (string $className, string $errorPattern) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->container->build($className)) + ->toThrow(RuntimeException::class, $errorPattern); + })->with([ + ['NonExistentClass', 'does not exist'], + [TestInterface::class, 'does not exist'], // Interfaces don't exist as classes + [AbstractClass::class, 'not instantiable'], + [PrivateConstructor::class, 'not instantiable'], + [ServiceWithScalarParam::class, 'Cannot resolve parameter'], + ]); + + it('cleans up state after errors', function () { + // ARRANGE + try { + $this->container->build(CircularA::class); + } catch (RuntimeException) { + // Expected + } + + // ACT - Should work fine after error + $result = $this->container->build(SimpleService::class); + + // ASSERT + expect($result)->toBeInstanceOf(SimpleService::class); + }); +}); diff --git a/tests/Unit/EnvServiceTest.php b/tests/Unit/EnvServiceTest.php new file mode 100644 index 00000000..692c8323 --- /dev/null +++ b/tests/Unit/EnvServiceTest.php @@ -0,0 +1,114 @@ +exists; + } + public function readFile(string $filename): string + { + if ($this->error) { + throw new \RuntimeException('Permission denied'); + } + return $this->content; + } + }; +} + +function setEnv(string $key, ?string $value): void +{ + if ($value === null) { + unset($_ENV[$key]); + putenv("{$key}"); + } else { + $_ENV[$key] = $value; + putenv("{$key}={$value}"); + } +} + +// +// EnvService Unit Tests +// ------------------------------------------------------------------------------- + +describe('EnvService', function () { + beforeEach(function () { + foreach (['TEST_KEY', 'API_KEY', 'KEY1', 'KEY2', 'MISSING_KEY', 'CUSTOM_KEY'] as $key) { + setEnv($key, null); + } + }); + + it('resolves environment variables from multiple sources with correct precedence', function ($env, $fileContent, $fileError, $keys, $expected) { + // ARRANGE + foreach ($env as $key => $value) { + setEnv($key, $value); + } + $service = new EnvService(mockFilesystem(!empty($fileContent), $fileContent, $fileError), '.env'); + + // ACT + $result = $service->get($keys, false); + + // ASSERT + expect($result)->toBe($expected); + + // CLEANUP + foreach (array_keys($env) as $key) { + setEnv($key, null); + } + })->with([ + // Single key scenarios + [[], 'API_KEY=from_file', false, 'API_KEY', 'from_file'], // File only + [['API_KEY' => 'from_env'], 'API_KEY=from_file', false, 'API_KEY', 'from_env'], // Env wins + [['API_KEY' => ''], 'API_KEY=from_file', false, 'API_KEY', 'from_file'], // Empty env ignored + [[], 'API_KEY=', false, 'API_KEY', null], // Empty file ignored + [[], '', false, 'API_KEY', null], // File missing + [[], 'API_KEY=value', true, 'API_KEY', null], // File read error + + // Multiple key scenarios (iterates in order, returns first found) + [['KEY1' => 'from_env'], 'KEY2=file_val', false, ['KEY1', 'KEY2'], 'from_env'], // First key in env + [[], "KEY1=file_val\nKEY2=other", false, ['KEY1', 'KEY2'], 'file_val'], // First key in file + [[], '', false, ['KEY1', 'KEY2'], null], // No keys found + ]); + + it('handles required vs optional parameters', function ($keys, $required, $expectsException, $expectedMessage) { + // ARRANGE + $service = new EnvService(mockFilesystem(false), '.env'); + + // ACT & ASSERT + if ($expectsException) { + expect(fn () => $service->get($keys, $required)) + ->toThrow(\RuntimeException::class, $expectedMessage); + } else { + expect($service->get($keys, $required))->toBeNull(); + } + })->with([ + ['MISSING_KEY', true, true, 'Missing environment variable: MISSING_KEY'], + [['KEY1', 'KEY2'], true, true, 'Missing environment variables: KEY1, KEY2'], + ['MISSING_KEY', false, false, null], + ['MISSING_KEY', true, true, 'Missing environment variable: MISSING_KEY'], // Default required=true + ]); + + it('loads from custom .env path and returns correct values', function () { + // ARRANGE + $service = new EnvService(mockFilesystem(true, 'CUSTOM_KEY=custom_value'), '/custom/.env'); + + // ACT + $result = $service->get('CUSTOM_KEY', false); + + // ASSERT + expect($result)->toBe('custom_value'); + }); +});