From 341b57c3bf2ba06c3b111fc2f24136ea05360a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Tue, 18 Aug 2026 18:46:31 +0200 Subject: [PATCH] feat(console): migrate interactive console commands to Laravel Prompts --- .../Console/Commands/CreateClientCommand.php | 59 ++++++------ .../RegenerateClientSecretCommand.php | 59 ++++++++---- .../Commands/ToggleClientStatusCommand.php | 53 +++++++---- .../Services/ClientConsoleResolver.php | 92 +++++++++++++------ .../Commands/CreateClientCommandTest.php | 51 +--------- .../RegenerateClientSecretCommandTest.php | 18 +--- .../ToggleClientStatusCommandTest.php | 3 + 7 files changed, 175 insertions(+), 160 deletions(-) diff --git a/src/Presentation/Console/Commands/CreateClientCommand.php b/src/Presentation/Console/Commands/CreateClientCommand.php index 6a3e5ae..2ee8c59 100644 --- a/src/Presentation/Console/Commands/CreateClientCommand.php +++ b/src/Presentation/Console/Commands/CreateClientCommand.php @@ -15,6 +15,9 @@ use Presentation\Console\Presenters\Clients\CreateClientView; use Shared\DDD\Usecase\Builder\UsecaseBuilder; use Shared\DDD\Usecase\Builder\UsecaseExecutor; +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\text; + final class CreateClientCommand extends Command { /** @@ -39,10 +42,6 @@ final class CreateClientCommand extends Command ): int { $clientName = $this->askForClientName(); - if ($clientName === null) { - return self::FAILURE; - } - if (! $this->confirmCreation($clientName)) { $this->warn('Client creation cancelled.'); @@ -65,41 +64,34 @@ final class CreateClientCommand extends Command return new Input($clientName); } - private function askForClientName(): ?ClientName + private function askForClientName(): ClientName { - $maxAttempts = 3; + $rawName = text( + label: 'Client name (e.g. "Ones Branding website")', + placeholder: 'E.g. Ones Branding website', + required: 'Client name cannot be empty.', + validate: function (string $value): ?string { + $trimmed = trim($value); - for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - /** @var string|null $nameInput */ - $nameInput = $this->ask('Client name (e.g. "Ones Branding website")'); - $nameInput = $nameInput !== null ? trim($nameInput) : ''; - - if ($nameInput === '') { - $this->error('Client name cannot be empty.'); - - continue; - } - - try { - $clientName = new ClientName($nameInput); - - if ($this->clientRepository->existsByName($clientName)) { - throw ClientNameAlreadyExistsException::forName($clientName->value); + if ($trimmed === '') { + return 'Client name cannot be empty.'; } - return $clientName; - } catch (\InvalidArgumentException | ClientNameAlreadyExistsException $e) { - $this->error($e->getMessage()); + try { + $clientName = new ClientName($trimmed); - if ($attempt < $maxAttempts) { - $this->warn(sprintf('You have %d attempt(s) remaining.', $maxAttempts - $attempt)); + if ($this->clientRepository->existsByName($clientName)) { + return ClientNameAlreadyExistsException::forName($trimmed)->getMessage(); + } + } catch (\InvalidArgumentException $e) { + return $e->getMessage(); } - } - } - $this->error('Maximum number of attempts reached. Aborting.'); + return null; + }, + ); - return null; + return new ClientName(trim($rawName)); } private function confirmCreation(ClientName $name): bool @@ -116,7 +108,10 @@ final class CreateClientCommand extends Command ], ); - return $this->confirm('Create this API client?', false); + return confirm( + label: 'Create this API client?', + default: false, + ); } private function displayCredentials(CreateClientView $view): void diff --git a/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php index d310b3e..410db0e 100644 --- a/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php +++ b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php @@ -10,6 +10,7 @@ use Application\Security\Models\ValueObjects\ClientIdentifier; use Application\Security\Usecases\RegenerateClientSecret\Input; use Application\Security\Usecases\RegenerateClientSecret\RegenerateClientSecret; use Illuminate\Console\Command; +use Illuminate\Contracts\Console\PromptsForMissingInput; use Presentation\Console\Presenters\Clients\RegenerateClientSecretPresenter; use Presentation\Console\Presenters\Clients\RegenerateClientSecretView; use Presentation\Console\Services\ClientConsoleResolver; @@ -18,13 +19,16 @@ use Shared\DDD\Usecase\Builder\UsecaseExecutor; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; -final class RegenerateClientSecretCommand extends Command +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\search; + +final class RegenerateClientSecretCommand extends Command implements PromptsForMissingInput { /** * @var string */ protected $signature = 'client:regenerate-secret - {identifier? : The identifier of the client}'; + {identifier : The identifier of the client}'; /** * @var string @@ -37,6 +41,22 @@ final class RegenerateClientSecretCommand extends Command parent::__construct(); } + /** + * @return array + */ + #[\Override] + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'identifier' => fn (): int | string => search( + label: 'Search client (by name, ID, identifier, or slug):', + placeholder: 'Type client name, identifier or slug...', + options: fn (string $value): array => $this->clientResolver->searchOptions($value), + scroll: 10, + ), + ]; + } + #[\Override] public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { @@ -89,33 +109,29 @@ final class RegenerateClientSecretCommand extends Command private function resolveClient(): ?Client { - $raw = $this->argument('identifier'); - $rawString = is_string($raw) ? $raw : $this->anticipate( - 'Search client (by name, ID, identifier, or slug):', - fn (string $input): array => $this->clientResolver->searchSuggestions($input), - ); + $raw = $this->argument('identifier'); - if (! is_string($rawString) || trim($rawString) === '') { + if (! is_string($raw) || trim($raw) === '') { $this->error('No client specified.'); return null; } - $inputString = trim($rawString); + $inputString = trim($raw); $client = $this->clientResolver->find($inputString); - if ($client === null) { - try { - new ClientIdentifier($inputString); - $this->error("Client '{$inputString}' not found."); - } catch (\InvalidArgumentException) { - $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); - } - - return null; + if ($client !== null) { + return $client; } - return $client; + try { + new ClientIdentifier($inputString); + $this->error("Client '{$inputString}' not found."); + } catch (\InvalidArgumentException) { + $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); + } + + return null; } private function confirmRegeneration(Client $client): bool @@ -123,7 +139,10 @@ final class RegenerateClientSecretCommand extends Command $this->newLine(); $this->warn(sprintf("Regenerating the secret will immediately invalidate the existing secret for client '%s'.", $client->name->value)); - return $this->confirm('Do you want to proceed?', false); + return confirm( + label: 'Do you want to proceed?', + default: false, + ); } private function displayResult(Client $client, RegenerateClientSecretView $view): void diff --git a/src/Presentation/Console/Commands/ToggleClientStatusCommand.php b/src/Presentation/Console/Commands/ToggleClientStatusCommand.php index 358a824..16b7782 100644 --- a/src/Presentation/Console/Commands/ToggleClientStatusCommand.php +++ b/src/Presentation/Console/Commands/ToggleClientStatusCommand.php @@ -10,6 +10,7 @@ use Application\Security\Models\ValueObjects\ClientIdentifier; use Application\Security\Usecases\ToggleClientStatus\Input; use Application\Security\Usecases\ToggleClientStatus\SetClientStatus; use Illuminate\Console\Command; +use Illuminate\Contracts\Console\PromptsForMissingInput; use Presentation\Console\Presenters\Clients\SetClientStatusPresenter; use Presentation\Console\Services\ClientConsoleResolver; use Shared\DDD\Usecase\Builder\UsecaseBuilder; @@ -18,13 +19,15 @@ use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Suggestion; -final class ToggleClientStatusCommand extends Command +use function Laravel\Prompts\search; + +final class ToggleClientStatusCommand extends Command implements PromptsForMissingInput { /** * @var string */ protected $signature = 'client:toggle-status - {identifier? : The identifier of the client} + {identifier : The identifier of the client} {status? : The desired status of the client (activate|deactivate)}'; /** @@ -38,6 +41,22 @@ final class ToggleClientStatusCommand extends Command parent::__construct(); } + /** + * @return array + */ + #[\Override] + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'identifier' => fn (): int | string => search( + label: 'Search client (by name, ID, identifier, or slug):', + placeholder: 'Type client name, identifier or slug...', + options: fn (string $value): array => $this->clientResolver->searchOptions($value), + scroll: 10, + ), + ]; + } + #[\Override] public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { @@ -103,33 +122,29 @@ final class ToggleClientStatusCommand extends Command private function resolveClient(): ?Client { - $raw = $this->argument('identifier'); - $rawString = is_string($raw) ? $raw : $this->anticipate( - 'Search client (by name, ID, identifier, or slug):', - fn (string $input): array => $this->clientResolver->searchSuggestions($input), - ); + $raw = $this->argument('identifier'); - if (! is_string($rawString) || trim($rawString) === '') { + if (! is_string($raw) || trim($raw) === '') { $this->error('No client specified.'); return null; } - $inputString = trim($rawString); + $inputString = trim($raw); $client = $this->clientResolver->find($inputString); - if ($client === null) { - try { - new ClientIdentifier($inputString); - $this->error("Client '{$inputString}' not found."); - } catch (\InvalidArgumentException) { - $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); - } - - return null; + if ($client !== null) { + return $client; } - return $client; + try { + new ClientIdentifier($inputString); + $this->error("Client '{$inputString}' not found."); + } catch (\InvalidArgumentException) { + $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); + } + + return null; } private function resolveDesiredStatus(Client $client): bool diff --git a/src/Presentation/Console/Services/ClientConsoleResolver.php b/src/Presentation/Console/Services/ClientConsoleResolver.php index c651533..4da4b40 100644 --- a/src/Presentation/Console/Services/ClientConsoleResolver.php +++ b/src/Presentation/Console/Services/ClientConsoleResolver.php @@ -6,11 +6,12 @@ namespace Presentation\Console\Services; use Application\Security\Models\Entities\Client; use Application\Security\Models\ValueObjects\ClientIdentifier; +use Application\Security\Models\ValueObjects\Entities\ClientId; use Application\Security\Ports\Repositories\ClientRepository; use Application\Security\Usecases\FindClient\FindClient; use Application\Security\Usecases\FindClient\Input as FindClientInput; use Presentation\Console\Presenters\Clients\FindClientPresenter; -use Presentation\Console\Presenters\Clients\FoundClientItem; +use Ramsey\Uuid\Uuid; use Shared\DDD\Usecase\Builder\UsecaseBuilder; use Shared\DDD\Usecase\Builder\UsecaseExecutor; use Shared\Pagination; @@ -46,16 +47,27 @@ final readonly class ClientConsoleResolver } /** - * Provide suggestions for console anticipate() prompt. + * Provide options for Laravel Prompts search() dropdown. * - * @return list + * @return array Key: identifier, Value: display label */ - public function searchSuggestions(string $input): array + public function searchOptions(string $value): array { - $trimmed = trim($input); + $trimmed = trim($value); if ($trimmed === '') { - return []; + $clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 10)); + $options = []; + foreach ($clients->getItems() as $client) { + $options[$client->identifier->value] = sprintf( + '%s (%s) - %s', + $client->name->value, + $client->isActive ? 'Active' : 'Inactive', + $client->identifier->value, + ); + } + + return $options; } try { @@ -66,17 +78,19 @@ final readonly class ClientConsoleResolver ->with(new FindClientPresenter()) ->run(); - return array_map( - static fn (FoundClientItem $item): string => $item->identifier, - $view->clients, - ); + $options = []; + foreach ($view->clients as $item) { + $options[$item->identifier] = $item->display; + } + + return $options; } catch (\Throwable) { return []; } } /** - * Find a Client by identifier, or by searching matching name, slug, or ID. + * Find a Client by identifier, display string, name, slug, or ID. */ public function find(string $input): ?Client { @@ -86,20 +100,36 @@ final readonly class ClientConsoleResolver return null; } - // 1. Direct lookup by exact ClientIdentifier format - try { - $identifier = new ClientIdentifier($trimmed); - $client = $this->clientRepository->findByIdentifier($identifier); - + if (preg_match('/arcadia_id_[a-f0-9]{32}/i', $trimmed, $matches) === 1) { + $client = $this->findByIdentifier($matches[0]); if ($client !== null) { return $client; } - } catch (\InvalidArgumentException) { - // Not a valid ClientIdentifier format, fallback to search via FindClient - $isExactIdentifier = false; } - // 2. Search via FindClient use case + $byUuid = $this->findByUuid($trimmed); + if ($byUuid !== null) { + return $byUuid; + } + + return $this->findBySearch($trimmed); + } + + private function findByUuid(string $trimmed): ?Client + { + try { + if (! Uuid::isValid($trimmed)) { + return null; + } + + return $this->clientRepository->find(new ClientId(Uuid::fromString($trimmed))); + } catch (\Throwable) { + return null; + } + } + + private function findBySearch(string $trimmed): ?Client + { try { /** @var \Presentation\Console\Presenters\Clients\FindClientView $view */ $view = UsecaseBuilder::using($this->executor) @@ -109,9 +139,7 @@ final readonly class ClientConsoleResolver ->run(); if (count($view->clients) === 1) { - $identifier = new ClientIdentifier($view->clients[0]->identifier); - - return $this->clientRepository->findByIdentifier($identifier); + return $this->findByIdentifier($view->clients[0]->identifier); } if (count($view->clients) > 1) { @@ -119,17 +147,25 @@ final readonly class ClientConsoleResolver if ( strcasecmp($found->name, $trimmed) === 0 || strcasecmp($found->id, $trimmed) === 0 ) { - $identifier = new ClientIdentifier($found->identifier); - - return $this->clientRepository->findByIdentifier($identifier); + return $this->findByIdentifier($found->identifier); } } } } catch (\Throwable) { - // Search error fallback - $searchFailed = true; + return null; } return null; } + + private function findByIdentifier(string | ClientIdentifier $input): ?Client + { + try { + $identifier = $input instanceof ClientIdentifier ? $input : new ClientIdentifier($input); + + return $this->clientRepository->findByIdentifier($identifier); + } catch (\InvalidArgumentException) { + return null; + } + } } diff --git a/tests/Feature/Console/Commands/CreateClientCommandTest.php b/tests/Feature/Console/Commands/CreateClientCommandTest.php index 98062bf..28d2ef0 100644 --- a/tests/Feature/Console/Commands/CreateClientCommandTest.php +++ b/tests/Feature/Console/Commands/CreateClientCommandTest.php @@ -17,7 +17,6 @@ describe('CreateClientCommand', function (): void { // WHEN $this->artisan('client:create') - // THEN ->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName) ->expectsConfirmation('Create this API client?', 'yes') @@ -40,7 +39,6 @@ describe('CreateClientCommand', function (): void { // WHEN $this->artisan('client:create') - // THEN ->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName) ->expectsConfirmation('Create this API client?', 'no') @@ -51,44 +49,14 @@ describe('CreateClientCommand', function (): void { expect($client)->toBeNull(); }); - it('retries when an empty name is provided', function (): void { - // GIVEN - /** @var \Tests\TestCase $this */ + it('fails when an empty name is provided', function (): void { + // GIVEN & WHEN & THEN $this->artisan('client:create') ->expectsQuestion('Client name (e.g. "Ones Branding website")', '') - ->expectsOutput('Client name cannot be empty.') - ->expectsQuestion('Client name (e.g. "Ones Branding website")', 'Valid Gateway') - - // WHEN - ->expectsConfirmation('Create this API client?', 'yes') - ->expectsOutputToContain("API client 'Valid Gateway' created successfully.") - - // THEN - ->assertSuccessful(); - - $client = ApiClient::query() - ->where('name', 'Valid Gateway') - ->first(); - expect($client)->not->toBeNull(); - }); - - it('fails after maximum retries with invalid names', function (): void { - // GIVEN - /** @var \Tests\TestCase $this */ - $this->artisan('client:create') - - // WHEN - ->expectsQuestion('Client name (e.g. "Ones Branding website")', '') - ->expectsOutput('Client name cannot be empty.') - ->expectsQuestion('Client name (e.g. "Ones Branding website")', '') - ->expectsOutput('Client name cannot be empty.') - ->expectsQuestion('Client name (e.g. "Ones Branding website")', '') - ->expectsOutput('Client name cannot be empty.') - ->expectsOutput('Maximum number of attempts reached. Aborting.') ->assertFailed(); }); - it('retries when the client name already exists', function (): void { + it('fails when the client name already exists', function (): void { // GIVEN /** @var \Tests\TestCase $this */ ApiClient::factory()->create([ @@ -96,18 +64,9 @@ describe('CreateClientCommand', function (): void { 'slug' => 'existing-gateway', ]); - // WHEN + // WHEN & THEN $this->artisan('client:create') ->expectsQuestion('Client name (e.g. "Ones Branding website")', 'Existing Gateway') - ->expectsOutput("A client with the name 'Existing Gateway' (or similar) already exists.") - ->expectsQuestion('Client name (e.g. "Ones Branding website")', 'New Gateway') - ->expectsConfirmation('Create this API client?', 'yes') - - // THEN - ->expectsOutputToContain("API client 'New Gateway' created successfully.") - ->assertSuccessful(); - - $client = ApiClient::query()->where('name', 'New Gateway')->first(); - expect($client)->not->toBeNull(); + ->assertFailed(); }); }); diff --git a/tests/Feature/Console/Commands/RegenerateClientSecretCommandTest.php b/tests/Feature/Console/Commands/RegenerateClientSecretCommandTest.php index 25d825f..c216a75 100644 --- a/tests/Feature/Console/Commands/RegenerateClientSecretCommandTest.php +++ b/tests/Feature/Console/Commands/RegenerateClientSecretCommandTest.php @@ -19,7 +19,6 @@ describe('RegenerateClientSecretCommand', function (): void { // WHEN $this->artisan('client:regenerate-secret', ['identifier' => $invalidIdentifier]) - // THEN ->expectsOutput("Invalid client identifier '{$invalidIdentifier}'. Expected a valid client identifier.") ->assertFailed(); @@ -32,7 +31,6 @@ describe('RegenerateClientSecretCommand', function (): void { // WHEN $this->artisan('client:regenerate-secret', ['identifier' => $nonExistentIdentifier]) - // THEN ->expectsOutput("Client '{$nonExistentIdentifier}' not found.") ->assertFailed(); @@ -55,7 +53,6 @@ describe('RegenerateClientSecretCommand', function (): void { // WHEN $this->artisan('client:regenerate-secret', ['identifier' => $identifier]) - // THEN ->expectsConfirmation('Do you want to proceed?', 'yes') ->expectsOutputToContain("Secret for client '{$identifier}' regenerated successfully.") @@ -84,7 +81,6 @@ describe('RegenerateClientSecretCommand', function (): void { // WHEN $this->artisan('client:regenerate-secret', ['identifier' => $identifier]) - // THEN ->expectsConfirmation('Do you want to proceed?', 'no') ->expectsOutput('Secret regeneration cancelled.') @@ -111,9 +107,11 @@ describe('RegenerateClientSecretCommand', function (): void { // WHEN $this->artisan('client:regenerate-secret') - // THEN ->expectsQuestion('Search client (by name, ID, identifier, or slug):', $identifier) + ->expectsChoice('Search client (by name, ID, identifier, or slug):', $identifier, [ + $identifier => "Searchable Client (Active) - {$identifier}", + ]) ->expectsConfirmation('Do you want to proceed?', 'yes') ->expectsOutputToContain("Secret for client '{$identifier}' regenerated successfully.") ->assertSuccessful(); @@ -122,16 +120,6 @@ describe('RegenerateClientSecretCommand', function (): void { expect($client->secret->value)->not->toBe($oldSecret); }); - it('outputs an error when no input provided during interactive prompt', function (): void { - // GIVEN & WHEN - $this->artisan('client:regenerate-secret') - - // THEN - ->expectsQuestion('Search client (by name, ID, identifier, or slug):', '') - ->expectsOutput('No client specified.') - ->assertFailed(); - }); - it('provides autocompletion suggestions for identifier', function (): void { // GIVEN /** @var \Tests\TestCase $this */ diff --git a/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php b/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php index a184e8f..8127baa 100644 --- a/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php +++ b/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php @@ -161,6 +161,9 @@ describe('ToggleClientStatusCommand', function (): void { $this->artisan('client:toggle-status') // THEN ->expectsQuestion('Search client (by name, ID, identifier, or slug):', $identifier) + ->expectsChoice('Search client (by name, ID, identifier, or slug):', $identifier, [ + $identifier => "Interactive Gateway (Active) - {$identifier}", + ]) ->expectsOutput("Client 'Interactive Gateway' (ID: {$client->id}) is now Inactive.") ->assertSuccessful();