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
3 changes: 2 additions & 1 deletion .cursor/rules/02-tests.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ it('does something specific', function () {
```php
expect($x)->toBeInstanceOf(Class::class); // Type-only testing
expect($x)->toBeArray(); // Generic assertions
expect($x)->not->toBeNull(); // Meaningless
expect($x)->not->toBeNull(); // Meaningless on its own
expect($x)->not->toBeNull()->and($x)->toContain('text'); // Redundant (toContain already guarantees non-null)
expect(true)->toBeTrue(); // Literally meaningless
sleep(...); // Use time mocking
```
Expand Down
140 changes: 139 additions & 1 deletion .cursor/rules/03-commands.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ protected function success(string $message): void {
- Input trait: [ConsoleInputTrait.php](mdc:app/Traits/ConsoleInputTrait.php)
- Output methods: `writeln()`, `info()`, `hr()`, `h1()`
- Status methods: `success()`, `error()`, `warning()`
- Input methods: `getOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()`
- Input methods: `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()`
- Helper methods: `showCommandHint()`

**When to Add New Methods:**
Expand Down Expand Up @@ -295,6 +295,144 @@ All wrappers automatically suppress extra spacing for cleaner output.
- Type-safe - preserves return types from prompts
- DRY - single command serves both use cases

### 🎯 Input Validation Pattern

Validation traits provide reusable validation logic that integrates with both Laravel Prompts and CLI options.

**Core Pattern:**

Validation methods accept `mixed`, check type first, then return `?string` (error message or `null` if valid):

```php
// ✅ CORRECT - Accept mixed with type guard
protected function validateNameInput(mixed $name): ?string
{
if (!is_string($name)) {
return 'Server name must be a string';
}

if (trim($name) === '') {
return 'Server name cannot be empty';
}

// Check uniqueness
$existing = $this->servers->findByName($name);
if ($existing !== null) {
return "Server '{$name}' already exists";
}

return null;
}

// ❌ WRONG - Throws exception (not compatible with Laravel Prompts)
protected function validateName(string $name): void
{
if (trim($name) === '') {
throw new \InvalidArgumentException('Name cannot be empty');
}
}

// ❌ WRONG - Type-hinted as string (not PHPStan-compliant)
protected function validateNameInput(string $name): ?string
{
// ...
}
```

**Using Validated Inputs:**

Use `getValidatedOptionOrPrompt()` for inputs that require validation:

```php
$name = $this->getValidatedOptionOrPrompt(
'name',
fn ($validate) => $this->promptText(
label: 'Server name:',
placeholder: 'web1',
validate: $validate
),
fn ($value) => $this->validateNameInput($value)
);

if ($name === null) {
return Command::FAILURE;
}
```

**How It Works:**

1. Validator is passed only once (third parameter)
2. Method automatically injects validator into prompt callback
3. Prompts validate interactively as user types
4. CLI options are validated after retrieval
5. Returns `null` on validation failure (error already displayed)
6. Returns validated value on success

**Naming Convention:**

- Validation methods: `validate*Input()` (returns `?string`)
- Exception-throwing validators: `validate*()` (for heavy I/O operations like git repo checks)

**Examples:**

- [ServerValidationTrait.php](mdc:app/Traits/ServerValidationTrait.php) - `validateHostInput()`, `validatePortInput()`, `validateNameInput()`
- [SiteValidationTrait.php](mdc:app/Traits/SiteValidationTrait.php) - `validateDomainInput()`, `validateBranchInput()`

**When to Use Exceptions:**

For validation that involves heavy I/O operations (network calls, external processes), use exception-throwing methods:

```php
// Heavy I/O operation - throw exceptions
protected function validateGitRepo(string $repo): void
{
$process = $this->proc->run(['git', 'ls-remote', '--exit-code', $repo]);

if (!$process->isSuccessful()) {
throw new \RuntimeException("Cannot access git repository '{$repo}'");
}
}

// Used in commands with try-catch for user-friendly error display
try {
$this->validateGitRepo($repo);
$this->success('Git repository is accessible');
} catch (\RuntimeException $e) {
$this->error($e->getMessage());
return Command::FAILURE;
}
```

**Testing Validation Traits:**

Create a test fixture class that uses the trait and exposes methods:

```php
class TestServerValidator
{
use ServerValidationTrait;

public function testValidateHost(mixed $host): ?string
{
return $this->validateHostInput($host);
}
}

// Test valid inputs return null
expect($validator->testValidateHost('192.168.1.100'))->toBeNull();

// Test invalid inputs return error messages
expect($validator->testValidateHost('invalid'))->toContain('valid');
```

**Benefits:**

- **Testable:** Easy to unit test without exception handling
- **Reusable:** Same method works for interactive prompts AND CLI options
- **User-friendly:** Integrates with Laravel Prompts' `validate` callback
- **Consistent:** Uniform pattern across all validation logic
- **Type-safe:** PHPStan compliant with proper type variance

**See also:** "Command Options & Input" section below for mandatory naming conventions.

### ⚙️ Command Options & Input (MANDATORY)
Expand Down
49 changes: 32 additions & 17 deletions app/Console/Server/ServerAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,40 +58,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int
//
// Gather server details

/** @var string $name */
$name = $this->getOptionOrPrompt(
/** @var string|null $name */
$name = $this->getValidatedOptionOrPrompt(
'name',
fn (): string => $this->promptText(
fn ($validate) => $this->promptText(
label: 'Server name:',
placeholder: 'web1',
required: true
)
required: true,
validate: $validate
),
fn ($value) => $this->validateNameInput($value)
);

/** @var string $host */
$host = $this->getOptionOrPrompt(
if ($name === null) {
return Command::FAILURE;
}

/** @var string|null $host */
$host = $this->getValidatedOptionOrPrompt(
'host',
fn (): string => $this->promptText(
fn ($validate) => $this->promptText(
label: 'Host/IP address:',
placeholder: '192.168.1.100',
required: true
)
required: true,
validate: $validate
),
fn ($value) => $this->validateHostInput($value)
);

$this->validateHost($host);
if ($host === null) {
return Command::FAILURE;
}

/** @var string $portString */
$portString = $this->getOptionOrPrompt(
/** @var string|null $portString */
$portString = $this->getValidatedOptionOrPrompt(
'port',
fn (): string => $this->promptText(
fn ($validate) => $this->promptText(
label: 'SSH port:',
default: '22',
required: true
)
required: true,
validate: $validate
),
fn ($value) => $this->validatePortInput($value)
);

if ($portString === null) {
return Command::FAILURE;
}

$port = (int) $portString;
$this->validatePort($port);

/** @var string $username */
$username = $this->getOptionOrPrompt(
Expand Down
25 changes: 23 additions & 2 deletions app/Repositories/ServerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,16 @@ public function create(ServerDTO $server): void
{
$this->assertInventoryLoaded();

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

$existingHost = $this->findByHost($server->host);
if (null !== $existingHost) {
throw new \RuntimeException("Host '{$server->host}' is already used by server '{$existingHost->name}'");
}

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

$this->inventory->set(self::PREFIX, $this->servers);
Expand All @@ -75,6 +80,22 @@ public function findByName(string $name): ?ServerDTO
return null;
}

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

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

return null;
}

/**
* Get all servers from the inventory.
*
Expand Down
49 changes: 49 additions & 0 deletions app/Traits/ConsoleInputTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,55 @@ protected function getOptionOrPrompt(
return $promptCallback();
}

/**
* Get option value or prompt user, with automatic validation.
*
* Combines getOptionOrPrompt with validation. The validator is automatically
* applied to both interactive prompts and CLI options.
*
* @param string $optionName The option name to check
* @param Closure(Closure): mixed $promptCallback Closure that receives validator and returns prompt result
* @param Closure(mixed): ?string $validator Validation closure that returns error message or null
*
* @return mixed The validated value, or null if validation failed
*
* @example
* $name = $this->getValidatedOptionOrPrompt(
* 'name',
* fn($validate) => $this->promptText(
* label: 'Server name:',
* validate: $validate
* ),
* fn($value) => $this->validateNameInput($value)
* );
* if ($name === null) {
* return Command::FAILURE;
* }
*/
protected function getValidatedOptionOrPrompt(
string $optionName,
Closure $promptCallback,
Closure $validator
): mixed {
// Pass validator to prompt callback
$value = $this->getOptionOrPrompt(
$optionName,
fn () => $promptCallback($validator)
);

// Validate if value came from CLI option (prompts already validated)
if ($this->input->getOption($optionName) !== null) {
$error = $validator($value);
if ($error !== null) {
$this->error($error);

return null;
}
}

return $value;
}

//
// Laravel Prompts Wrappers
// -------------------------------------------------------------------------------
Expand Down
40 changes: 17 additions & 23 deletions app/Traits/ServerHelpersTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,7 @@
trait ServerHelpersTrait
{
/**
* Display server details.
*/
protected function displayServerDeets(ServerDTO $server): void
{
$this->writeln([
" Name: <fg=gray>{$server->name}</>",
" Host: <fg=gray>{$server->host}</>",
" Port: <fg=gray>{$server->port}</>",
" User: <fg=gray>{$server->username}</>",
' Key: <fg=gray>'.($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)').'</>',
' '
]);
}

/**
* Select a server from inventory by server option or interactive prompt.
* Select a server from inventory by name option or interactive prompt.
*
* @return array{server: ServerDTO|null, exit_code: int} Server DTO and exit code (SUCCESS if empty inventory, FAILURE if not found)
*/
Expand Down Expand Up @@ -71,13 +56,7 @@ protected function selectServer(string $optionName = 'server', string $promptLab
//
// Find server by name

$server = null;
foreach ($allServers as $s) {
if ($s->name === $name) {
$server = $s;
break;
}
}
$server = $this->servers->findByName($name);

if ($server === null) {
$this->error("Server '{$name}' not found in inventory");
Expand All @@ -88,4 +67,19 @@ protected function selectServer(string $optionName = 'server', string $promptLab
return ['server' => $server, 'exit_code' => Command::SUCCESS];
}

/**
* Display server details.
*/
protected function displayServerDeets(ServerDTO $server): void
{
$this->writeln([
" Name: <fg=gray>{$server->name}</>",
" Host: <fg=gray>{$server->host}</>",
" Port: <fg=gray>{$server->port}</>",
" User: <fg=gray>{$server->username}</>",
' Key: <fg=gray>'.($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)').'</>',
' '
]);
}

}
Loading