feat(console): migrate interactive console commands to Laravel Prompts

This commit is contained in:
Jérémy Munsch 2026-08-18 18:46:31 +02:00
parent 7f57f16425
commit 341b57c3bf
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
7 changed files with 175 additions and 160 deletions

View file

@ -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

View file

@ -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<string, \Closure(): (int|string)>
*/
#[\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

View file

@ -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<string, \Closure(): (int|string)>
*/
#[\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

View file

@ -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<string>
* @return array<string, string> 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;
}
}
}

View file

@ -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();
});
});

View file

@ -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 */

View file

@ -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();