From 9387cb22b359dc568e3263c6738b1f7fbcc0a33b Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:11:46 +0100 Subject: [PATCH 01/16] Add web interface support for Laravel Prompts --- packages/prompts/config/prompts.php | 58 +-- .../filament/components/run-command.blade.php | 34 ++ .../filament/pages/run-command.blade.php | 41 ++ .../Components/RunCommandComponent.php | 375 ++++++++++++++++++ .../src/Filament/Pages/RunCommandPage.php | 65 +++ .../prompts/src/Filament/PromptsPlugin.php | 38 ++ .../prompts/src/PromptsServiceProvider.php | 24 +- .../src/Support/PendingPromptsException.php | 27 ++ .../src/Support/PromptResponseStore.php | 50 +++ .../prompts/src/Support/WebPromptRuntime.php | 348 ++++++++++++++++ 10 files changed, 1020 insertions(+), 40 deletions(-) create mode 100644 packages/prompts/resources/views/filament/components/run-command.blade.php create mode 100644 packages/prompts/resources/views/filament/pages/run-command.blade.php create mode 100644 packages/prompts/src/Filament/Components/RunCommandComponent.php create mode 100644 packages/prompts/src/Filament/Pages/RunCommandPage.php create mode 100644 packages/prompts/src/Filament/PromptsPlugin.php create mode 100644 packages/prompts/src/Support/PendingPromptsException.php create mode 100644 packages/prompts/src/Support/PromptResponseStore.php create mode 100644 packages/prompts/src/Support/WebPromptRuntime.php diff --git a/packages/prompts/config/prompts.php b/packages/prompts/config/prompts.php index 425331ef9..848515a16 100644 --- a/packages/prompts/config/prompts.php +++ b/packages/prompts/config/prompts.php @@ -2,55 +2,37 @@ /* |-------------------------------------------------------------------------- -| Moox Configuration +| Prompts Configuration |-------------------------------------------------------------------------- | -| This configuration file uses translatable strings. If you want to -| translate the strings, you can do so in the language files -| published from moox_core. Example: -| -| 'trans//core::core.all', -| loads from common.php -| outputs 'All' +| This configuration file defines which Artisan commands are allowed to be +| executed through the web interface using prompts. | */ -return [ - - 'readonly' => false, - - 'resources' => [ - 'item' => [ - 'single' => 'trans//item::item.item', - 'plural' => 'trans//item::item.items', - 'tabs' => [ - 'all' => [ - 'label' => 'trans//core::core.all', - 'icon' => 'gmdi-filter-list', - 'query' => [ - [ - 'field' => 'title', - 'operator' => '!=', - 'value' => null, - ], - ], - ], - ], - ], - ], - 'relations' => [], +return [ /* |-------------------------------------------------------------------------- - | Navigation + | Allowed Commands |-------------------------------------------------------------------------- | - | The navigation group and sort of the Resource, - | and if the panel is enabled. + | List of Artisan command names that are allowed to be executed through + | the web interface. Only commands listed here will be available in the + | Command Runner page. + | + | Example: + | 'allowed_commands' => [ + | 'prompts:test-flow', + | 'prompts:test-web', + | ], | */ - 'auth' => [ - 'user' => 'Moox\\DevTools\\Models\\TestUser', + + 'allowed_commands' => [ + 'prompts:test-flow', + 'prompts:publish-news-config', + // Add more commands here as needed ], - 'navigation_group' => 'DEV', + ]; diff --git a/packages/prompts/resources/views/filament/components/run-command.blade.php b/packages/prompts/resources/views/filament/components/run-command.blade.php new file mode 100644 index 000000000..38b390337 --- /dev/null +++ b/packages/prompts/resources/views/filament/components/run-command.blade.php @@ -0,0 +1,34 @@ +
+ @if($error) + + + Fehler + +

{{ $error }}

+
+ @elseif($isComplete) + + + Command erfolgreich abgeschlossen! + + @if($output) +
{{ $output }}
+ @endif +
+ @elseif($currentPrompt) + {{ $this->form }} +
+ + Weiter + +
+ @else + + + Command wird gestartet... + + + + @endif +
\ No newline at end of file diff --git a/packages/prompts/resources/views/filament/pages/run-command.blade.php b/packages/prompts/resources/views/filament/pages/run-command.blade.php new file mode 100644 index 000000000..90ca78c7e --- /dev/null +++ b/packages/prompts/resources/views/filament/pages/run-command.blade.php @@ -0,0 +1,41 @@ + + @if(!$started) + + + Command auswählen + + + @if(empty($availableCommands)) +

Keine Commands verfügbar. Bitte konfigurieren Sie die erlaubten Commands in der Konfiguration.

+ @else +
+ + + + @foreach($availableCommands as $commandName => $description) + + @endforeach + + + +
+ + Command starten + +
+
+ @endif +
+ @else + @livewire('moox-prompts.filament.components.run-command-component', [ + 'command' => $selectedCommand, + 'commandInput' => [] + ], key('run-command-' . $selectedCommand)) + +
+ + Zurück + +
+ @endif +
diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php new file mode 100644 index 000000000..41aa4ae5a --- /dev/null +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -0,0 +1,375 @@ +responseStore = new PromptResponseStore; + app()->instance('moox.prompts.response_store', $this->responseStore); + } + + public function mount(string $command = '', array $commandInput = []): void + { + $this->command = $command; + $this->commandInput = $commandInput; + $this->answers = []; + $this->isComplete = false; + $this->currentPrompt = null; + $this->output = ''; + $this->error = null; + + $this->responseStore->resetCounter(); + + if ($command) { + $this->runCommand(); + } + } + + protected function runCommand(): void + { + $this->error = null; + $this->isComplete = false; + + try { + $this->responseStore->resetCounter(); + + foreach ($this->answers as $promptId => $answer) { + $this->responseStore->set($promptId, $answer); + } + + app()->instance('moox.prompts.response_store', $this->responseStore); + + $commandInstance = app(\Illuminate\Contracts\Console\Kernel::class) + ->all()[$this->command] ?? null; + + if (!$commandInstance) { + $this->error = "Command nicht gefunden: {$this->command}"; + return; + } + + $commandInstance->setLaravel(app()); + $output = new BufferedOutput(); + + $outputStyle = new OutputStyle( + new ArrayInput($this->commandInput), + $output + ); + $commandInstance->setOutput($outputStyle); + + try { + $commandInstance->run( + new ArrayInput($this->commandInput), + $output + ); + + $this->output = $output->fetch(); + $this->isComplete = true; + $this->currentPrompt = null; + } catch (PendingPromptsException $e) { + $prompt = $e->getPrompt(); + $this->currentPrompt = $prompt; + + $promptId = $prompt['id'] ?? null; + if ($promptId && isset($this->answers[$promptId])) { + $value = $this->answers[$promptId]; + if ($prompt['method'] === 'multiselect') { + if (!is_array($value)) { + if ($value === true) { + $params = $prompt['params'] ?? []; + $options = $params[1] ?? []; + $value = array_keys($options); + } else { + $value = []; + } + } + } + $this->form->fill([$promptId => $value]); + } + } + } catch (\Exception $e) { + $this->error = $e->getMessage(); + $this->currentPrompt = null; + } + } + + public function submitPrompt(): void + { + if (!$this->currentPrompt) { + return; + } + + $promptId = $this->currentPrompt['id'] ?? null; + if (!$promptId) { + return; + } + + try { + if ($this->currentPrompt['method'] === 'multiselect') { + $params = $this->currentPrompt['params'] ?? []; + $options = $params[1] ?? []; + $answer = []; + + foreach (array_keys($options) as $key) { + $checkboxId = $promptId . '_' . $key; + if (isset($this->data[$checkboxId]) && $this->data[$checkboxId] === true) { + $answer[] = $key; + } + } + } else { + $answer = $this->data[$promptId] ?? null; + } + + if ($this->currentPrompt['method'] !== 'multiselect' && ($answer === null || ($answer === '' && $this->currentPrompt['method'] !== 'confirm'))) { + $allRequestData = request()->all(); + $updates = data_get($allRequestData, 'components.0.updates', []); + + if (is_array($updates) || is_object($updates)) { + $updateKey = 'data.' . $promptId; + if (isset($updates[$updateKey])) { + $answer = $updates[$updateKey]; + } + if ($answer === null && isset($updates[$promptId])) { + $answer = $updates[$promptId]; + } + if ($answer === null) { + foreach ($updates as $key => $value) { + if (str_ends_with($key, '.' . $promptId) || $key === $promptId) { + $answer = $value; + break; + } + } + } + } + } + + if ($this->currentPrompt['method'] !== 'multiselect' && ($answer === null || ($answer === '' && $this->currentPrompt['method'] !== 'confirm'))) { + $rawState = $this->form->getRawState(); + $answer = $rawState[$promptId] ?? null; + } + + if ($this->currentPrompt['method'] === 'confirm' && $answer === null) { + $answer = false; + } + + if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && !is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { + try { + $data = $this->form->getState(); + $answer = $data[$promptId] ?? null; + + if ($this->currentPrompt['method'] === 'multiselect') { + if (!is_array($answer)) { + if ($answer === true) { + $params = $this->currentPrompt['params'] ?? []; + $options = $params[1] ?? []; + $answer = array_keys($options); + } else { + $answer = []; + } + } + } + } catch (\Illuminate\Validation\ValidationException $e) { + return; + } + } + + if (($answer === null || $answer === '') && $this->currentPrompt['method'] === 'select') { + $params = $this->currentPrompt['params'] ?? []; + $options = $params[1] ?? []; + if (!empty($options)) { + $answer = array_key_first($options); + } + } + + if ($this->currentPrompt['method'] === 'confirm') { + if ($answer === null) { + return; + } + } elseif ($answer === null || $answer === '') { + return; + } + + if ($this->currentPrompt['method'] === 'multiselect') { + if (!is_array($answer)) { + if ($answer === true) { + $params = $this->currentPrompt['params'] ?? []; + $options = $params[1] ?? []; + $answer = array_keys($options); + } elseif ($answer !== null && $answer !== '') { + $answer = [$answer]; + } else { + $answer = []; + } + } + + if (!is_array($answer)) { + $answer = []; + } + } + + if ($this->currentPrompt['method'] === 'confirm') { + if (!is_bool($answer)) { + $answer = (bool) $answer; + } + } + + $this->answers[$promptId] = $answer; + $this->currentPrompt = null; + $this->runCommand(); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } + } + + protected function getForms(): array + { + return ['form']; + } + + public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema + { + return $schema + ->components($this->getFormSchema()) + ->statePath('data'); + } + + protected function getFormSchema(): array + { + if ($this->isComplete || !$this->currentPrompt) { + return []; + } + + $promptId = $this->currentPrompt['id'] ?? 'prompt_0'; + $method = $this->currentPrompt['method'] ?? 'text'; + $params = $this->currentPrompt['params'] ?? []; + + if ($method === 'multiselect') { + return $this->createMultiselectFields($promptId, $params); + } + + $field = $this->createFieldFromPrompt($promptId, $method, $params); + + if (!$field) { + return []; + } + + return [$field]; + } + + protected function createMultiselectFields(string $promptId, array $params): array + { + $label = $params[0] ?? ''; + $required = ($params[3] ?? false) !== false; + $defaultValue = $this->answers[$promptId] ?? null; + $options = $params[1] ?? []; + + $fields = []; + + $fields[] = Placeholder::make($promptId . '_label') + ->label($label) + ->content(''); + + foreach ($options as $key => $optionLabel) { + $checkboxId = $promptId . '_' . $key; + $isChecked = is_array($defaultValue) && in_array($key, $defaultValue); + + $fields[] = Checkbox::make($checkboxId) + ->label($optionLabel) + ->default($isChecked) + ->live(onBlur: false); + } + + return $fields; + } + + protected function createFieldFromPrompt(string $promptId, string $method, array $params): ?\Filament\Forms\Components\Field + { + $label = $params[0] ?? ''; + $required = ($params[3] ?? false) !== false; + $defaultValue = $this->answers[$promptId] ?? null; + + return match($method) { + 'text' => TextInput::make($promptId) + ->label($label) + ->placeholder($params[1] ?? '') + ->default($defaultValue ?? $params[2] ?? '') + ->required($required) + ->hint($params[6] ?? null) + ->live(onBlur: false), + + 'textarea' => Textarea::make($promptId) + ->label($label) + ->placeholder($params[1] ?? '') + ->default($defaultValue ?? $params[2] ?? '') + ->required($required) + ->rows(5) + ->hint($params[6] ?? null), + + 'password' => TextInput::make($promptId) + ->label($label) + ->password() + ->placeholder($params[1] ?? '') + ->default($defaultValue ?? '') + ->required($required) + ->hint($params[6] ?? null) + ->live(onBlur: false), + + 'select' => Select::make($promptId) + ->label($label) + ->options($params[1] ?? []) + ->default($defaultValue ?? $params[2] ?? null) + ->required($required) + ->hint($params[4] ?? null) + ->live(onBlur: false), + + 'multiselect' => null, + + 'confirm' => Radio::make($promptId) + ->label($label) + ->options([ + true => 'Ja', + false => 'Nein', + ]) + ->default($defaultValue ?? $params[1] ?? false) + ->required($required) + ->hint($params[6] ?? null) + ->live(onBlur: false), + + default => null, + }; + } + + public function render() + { + return view('moox-prompts::filament.components.run-command'); + } +} diff --git a/packages/prompts/src/Filament/Pages/RunCommandPage.php b/packages/prompts/src/Filament/Pages/RunCommandPage.php new file mode 100644 index 000000000..aad8e2937 --- /dev/null +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -0,0 +1,65 @@ +availableCommands = $this->getAvailableCommands(); + } + + public function startCommand(): void + { + if ($this->selectedCommand) { + $this->started = true; + } + } + + public function resetCommand(): void + { + $this->started = false; + $this->selectedCommand = ''; + } + + protected function getAvailableCommands(): array + { + $allowedCommands = config('prompts.allowed_commands', []); + + if (empty($allowedCommands)) { + return []; + } + + $allCommands = app(\Illuminate\Contracts\Console\Kernel::class)->all(); + + $available = []; + foreach ($allowedCommands as $commandName) { + if (isset($allCommands[$commandName])) { + $command = $allCommands[$commandName]; + $available[$commandName] = $command->getDescription() ?: $commandName; + } + } + + ksort($available); + + return $available; + } +} diff --git a/packages/prompts/src/Filament/PromptsPlugin.php b/packages/prompts/src/Filament/PromptsPlugin.php new file mode 100644 index 000000000..c6c362904 --- /dev/null +++ b/packages/prompts/src/Filament/PromptsPlugin.php @@ -0,0 +1,38 @@ +pages([ + RunCommandPage::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } + + public static function get(): static + { + return filament(app(static::class)->getId()); + } +} + diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index 170c19457..82eae9c3e 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -5,6 +5,8 @@ use Moox\Core\MooxServiceProvider; use Moox\Prompts\Support\CliPromptRuntime; use Moox\Prompts\Support\PromptRuntime; +use Moox\Prompts\Support\PromptResponseStore; +use Moox\Prompts\Support\WebPromptRuntime; use Spatie\LaravelPackageTools\Package; class PromptsServiceProvider extends MooxServiceProvider @@ -13,15 +15,24 @@ public function configureMoox(Package $package): void { $package ->name('moox-prompts') - ->hasConfigFile('prompts'); + ->hasConfigFile('prompts') + ->hasViews(); } public function register() { parent::register(); + $this->app->bind('moox.prompts.response_store', function ($app) { + return new PromptResponseStore; + }); + $this->app->singleton(PromptRuntime::class, function ($app) { - return new CliPromptRuntime; + if (php_sapi_name() === 'cli') { + return new CliPromptRuntime; + } + + return new WebPromptRuntime; }); } @@ -30,5 +41,14 @@ public function boot(): void parent::boot(); require_once __DIR__.'/functions.php'; + + $this->loadViewsFrom(__DIR__.'/../resources/views', 'moox-prompts'); + + if (class_exists(\Livewire\Livewire::class)) { + \Livewire\Livewire::component( + 'moox-prompts.filament.components.run-command-component', + \Moox\Prompts\Filament\Components\RunCommandComponent::class + ); + } } } diff --git a/packages/prompts/src/Support/PendingPromptsException.php b/packages/prompts/src/Support/PendingPromptsException.php new file mode 100644 index 000000000..65c675ba1 --- /dev/null +++ b/packages/prompts/src/Support/PendingPromptsException.php @@ -0,0 +1,27 @@ +prompts = [$prompt]; + } + + public function getPrompts(): array + { + return $this->prompts; + } + + public function getPrompt(): ?array + { + return $this->prompts[0] ?? null; + } +} + diff --git a/packages/prompts/src/Support/PromptResponseStore.php b/packages/prompts/src/Support/PromptResponseStore.php new file mode 100644 index 000000000..750df4664 --- /dev/null +++ b/packages/prompts/src/Support/PromptResponseStore.php @@ -0,0 +1,50 @@ +responses[$promptId] = $value; + } + + public function get(string $promptId): mixed + { + return $this->responses[$promptId] ?? null; + } + + public function has(string $promptId): bool + { + return isset($this->responses[$promptId]); + } + + public function clear(): void + { + $this->responses = []; + $this->promptCounter = 0; + } + + public function all(): array + { + return $this->responses; + } + + public function getNextPromptId(string $method): string + { + return 'prompt_' . (++$this->promptCounter); + } + + public function resetCounter(): void + { + $this->promptCounter = 0; + } + + public function setCounter(int $count): void + { + $this->promptCounter = $count; + } +} diff --git a/packages/prompts/src/Support/WebPromptRuntime.php b/packages/prompts/src/Support/WebPromptRuntime.php new file mode 100644 index 000000000..9187b4afe --- /dev/null +++ b/packages/prompts/src/Support/WebPromptRuntime.php @@ -0,0 +1,348 @@ +responseStore = app('moox.prompts.response_store'); + } + + protected function generatePromptId(string $method): string + { + return $this->responseStore->getNextPromptId($method); + } + + protected function checkOrThrow(string $promptId, array $promptData): mixed + { + if ($this->responseStore->has($promptId)) { + $value = $this->responseStore->get($promptId); + + if ($promptData['method'] === 'multiselect') { + if (!is_array($value)) { + if ($value === true) { + $options = $promptData['params'][1] ?? []; + return array_keys($options); + } + if ($value !== null && $value !== '') { + return [$value]; + } + return []; + } + return $value; + } + + return $value; + } + + throw new PendingPromptsException([ + 'id' => $promptId, + 'method' => $promptData['method'], + 'params' => $promptData['params'], + ]); + } + + public function text( + string $label, + string $placeholder = '', + mixed $default = null, + bool|string $required = false, + callable|string|array|null $validate = null, + string $hint = '', + callable|string|null $transform = null, + ): string { + $promptId = $this->generatePromptId('text'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'text', + 'params' => [ + $label, + $placeholder, + $default, + $required, + $validate, + $hint, + $transform, + ], + ]); + } + + public function textarea( + string $label, + string $placeholder = '', + bool|string $required = false, + callable|string|array|null $validate = null, + string $hint = '', + callable|string|null $transform = null, + ): string { + $promptId = $this->generatePromptId('textarea'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'textarea', + 'params' => [ + $label, + $placeholder, + $required, + $validate, + $hint, + $transform, + ], + ]); + } + + public function password( + string $label, + string $placeholder = '', + bool|string $required = false, + callable|string|array|null $validate = null, + string $hint = '', + callable|string|null $transform = null, + ): string { + $promptId = $this->generatePromptId('password'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'password', + 'params' => [ + $label, + $placeholder, + $required, + $validate, + $hint, + $transform, + ], + ]); + } + + public function confirm( + string $label, + bool $default = false, + bool|string $required = false, + string $yes = 'I accept', + string $no = 'I decline', + string $hint = '', + ): bool { + $promptId = $this->generatePromptId('confirm'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'confirm', + 'params' => [ + $label, + $default, + $required, + $yes, + $no, + $hint, + ], + ]); + } + + public function select( + string $label, + array $options, + mixed $default = null, + ?string $scroll = null, + string $hint = '', + callable|string|array|null $validate = null, + callable|string|null $transform = null, + ): mixed { + $promptId = $this->generatePromptId('select'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'select', + 'params' => [ + $label, + $options, + $default, + $scroll, + $hint, + $validate, + $transform, + ], + ]); + } + + public function multiselect( + string $label, + array $options, + array $default = [], + bool|string $required = false, + ?string $scroll = null, + string $hint = '', + callable|string|array|null $validate = null, + callable|string|null $transform = null, + ): array { + $promptId = $this->generatePromptId('multiselect'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'multiselect', + 'params' => [ + $label, + $options, + $default, + $required, + $scroll, + $hint, + $validate, + $transform, + ], + ]); + } + + public function suggest( + string $label, + array|Closure $options, + mixed $default = null, + bool|string $required = false, + callable|string|array|null $validate = null, + string $placeholder = '', + string $hint = '', + callable|string|null $transform = null, + ): mixed { + $promptId = $this->generatePromptId('suggest'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'suggest', + 'params' => [ + $label, + $options, + $default, + $required, + $validate, + $placeholder, + $hint, + $transform, + ], + ]); + } + + public function search( + string $label, + Closure $options, + bool|string $required = false, + callable|string|array|null $validate = null, + string $placeholder = '', + ?string $scroll = null, + string $hint = '', + callable|string|null $transform = null, + ): mixed { + $promptId = $this->generatePromptId('search'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'search', + 'params' => [ + $label, + $options, + $required, + $validate, + $placeholder, + $scroll, + $hint, + $transform, + ], + ]); + } + + public function multisearch( + string $label, + Closure $options, + bool|string $required = false, + callable|string|array|null $validate = null, + string $placeholder = '', + ?string $scroll = null, + string $hint = '', + callable|string|null $transform = null, + ): array { + $promptId = $this->generatePromptId('multisearch'); + + return $this->checkOrThrow($promptId, [ + 'method' => 'multisearch', + 'params' => [ + $label, + $options, + $required, + $validate, + $placeholder, + $scroll, + $hint, + $transform, + ], + ]); + } + + public function pause(string $message = 'Press ENTER to continue'): void + { + } + + public function note(string $message): void + { + } + + public function info(string $message): void + { + } + + public function warning(string $message): void + { + } + + public function error(string $message): void + { + } + + public function alert(string $message): void + { + } + + public function intro(string $message): void + { + } + + public function outro(string $message): void + { + } + + public function table(array $headers, array $rows): void + { + } + + public function spin(Closure $callback, string $message = ''): mixed + { + return $callback(); + } + + public function progress( + string $label, + iterable|int $steps, + ?Closure $callback = null, + string $hint = '', + ): Progress|array { + if (is_int($steps)) { + return []; + } + + $results = []; + foreach ($steps as $step) { + if ($callback) { + $results[] = $callback($step); + } + } + + return $results; + } + + public function clear(): void + { + } + + public function form(): FormBuilder + { + throw new \RuntimeException('Form builder not yet implemented for web context'); + } +} From 8c0b9f5a81611e24d924a4f821fad7f39e2454f2 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:12:28 +0000 Subject: [PATCH 02/16] Fix styling --- .../Components/RunCommandComponent.php | 90 ++++++++++--------- .../src/Filament/Pages/RunCommandPage.php | 26 +++--- .../prompts/src/Filament/PromptsPlugin.php | 1 - .../prompts/src/PromptsServiceProvider.php | 2 +- .../src/Support/PendingPromptsException.php | 1 - .../src/Support/PromptResponseStore.php | 3 +- .../prompts/src/Support/WebPromptRuntime.php | 67 ++++++-------- 7 files changed, 91 insertions(+), 99 deletions(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 41aa4ae5a..1c709d0d9 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -2,32 +2,39 @@ namespace Moox\Prompts\Filament\Components; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\Select; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Radio; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; +use Illuminate\Console\OutputStyle; use Livewire\Component; use Moox\Prompts\Support\PendingPromptsException; use Moox\Prompts\Support\PromptResponseStore; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -use Illuminate\Console\OutputStyle; class RunCommandComponent extends Component implements HasForms { use InteractsWithForms; public string $command = ''; + public array $commandInput = []; + public ?array $currentPrompt = null; + public string $output = ''; + public bool $isComplete = false; + public ?string $error = null; + public array $answers = []; + public array $data = []; protected PromptResponseStore $responseStore; @@ -47,9 +54,9 @@ public function mount(string $command = '', array $commandInput = []): void $this->currentPrompt = null; $this->output = ''; $this->error = null; - + $this->responseStore->resetCounter(); - + if ($command) { $this->runCommand(); } @@ -62,23 +69,24 @@ protected function runCommand(): void try { $this->responseStore->resetCounter(); - + foreach ($this->answers as $promptId => $answer) { $this->responseStore->set($promptId, $answer); } - + app()->instance('moox.prompts.response_store', $this->responseStore); $commandInstance = app(\Illuminate\Contracts\Console\Kernel::class) ->all()[$this->command] ?? null; - if (!$commandInstance) { + if (! $commandInstance) { $this->error = "Command nicht gefunden: {$this->command}"; + return; } $commandInstance->setLaravel(app()); - $output = new BufferedOutput(); + $output = new BufferedOutput; $outputStyle = new OutputStyle( new ArrayInput($this->commandInput), @@ -103,7 +111,7 @@ protected function runCommand(): void if ($promptId && isset($this->answers[$promptId])) { $value = $this->answers[$promptId]; if ($prompt['method'] === 'multiselect') { - if (!is_array($value)) { + if (! is_array($value)) { if ($value === true) { $params = $prompt['params'] ?? []; $options = $params[1] ?? []; @@ -124,12 +132,12 @@ protected function runCommand(): void public function submitPrompt(): void { - if (!$this->currentPrompt) { + if (! $this->currentPrompt) { return; } $promptId = $this->currentPrompt['id'] ?? null; - if (!$promptId) { + if (! $promptId) { return; } @@ -138,9 +146,9 @@ public function submitPrompt(): void $params = $this->currentPrompt['params'] ?? []; $options = $params[1] ?? []; $answer = []; - + foreach (array_keys($options) as $key) { - $checkboxId = $promptId . '_' . $key; + $checkboxId = $promptId.'_'.$key; if (isset($this->data[$checkboxId]) && $this->data[$checkboxId] === true) { $answer[] = $key; } @@ -152,9 +160,9 @@ public function submitPrompt(): void if ($this->currentPrompt['method'] !== 'multiselect' && ($answer === null || ($answer === '' && $this->currentPrompt['method'] !== 'confirm'))) { $allRequestData = request()->all(); $updates = data_get($allRequestData, 'components.0.updates', []); - + if (is_array($updates) || is_object($updates)) { - $updateKey = 'data.' . $promptId; + $updateKey = 'data.'.$promptId; if (isset($updates[$updateKey])) { $answer = $updates[$updateKey]; } @@ -163,7 +171,7 @@ public function submitPrompt(): void } if ($answer === null) { foreach ($updates as $key => $value) { - if (str_ends_with($key, '.' . $promptId) || $key === $promptId) { + if (str_ends_with($key, '.'.$promptId) || $key === $promptId) { $answer = $value; break; } @@ -181,13 +189,13 @@ public function submitPrompt(): void $answer = false; } - if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && !is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { + if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && ! is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { try { $data = $this->form->getState(); $answer = $data[$promptId] ?? null; - + if ($this->currentPrompt['method'] === 'multiselect') { - if (!is_array($answer)) { + if (! is_array($answer)) { if ($answer === true) { $params = $this->currentPrompt['params'] ?? []; $options = $params[1] ?? []; @@ -201,15 +209,15 @@ public function submitPrompt(): void return; } } - + if (($answer === null || $answer === '') && $this->currentPrompt['method'] === 'select') { $params = $this->currentPrompt['params'] ?? []; $options = $params[1] ?? []; - if (!empty($options)) { + if (! empty($options)) { $answer = array_key_first($options); } } - + if ($this->currentPrompt['method'] === 'confirm') { if ($answer === null) { return; @@ -217,9 +225,9 @@ public function submitPrompt(): void } elseif ($answer === null || $answer === '') { return; } - + if ($this->currentPrompt['method'] === 'multiselect') { - if (!is_array($answer)) { + if (! is_array($answer)) { if ($answer === true) { $params = $this->currentPrompt['params'] ?? []; $options = $params[1] ?? []; @@ -230,18 +238,18 @@ public function submitPrompt(): void $answer = []; } } - - if (!is_array($answer)) { + + if (! is_array($answer)) { $answer = []; } } - + if ($this->currentPrompt['method'] === 'confirm') { - if (!is_bool($answer)) { + if (! is_bool($answer)) { $answer = (bool) $answer; } } - + $this->answers[$promptId] = $answer; $this->currentPrompt = null; $this->runCommand(); @@ -264,7 +272,7 @@ public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema protected function getFormSchema(): array { - if ($this->isComplete || !$this->currentPrompt) { + if ($this->isComplete || ! $this->currentPrompt) { return []; } @@ -278,7 +286,7 @@ protected function getFormSchema(): array $field = $this->createFieldFromPrompt($promptId, $method, $params); - if (!$field) { + if (! $field) { return []; } @@ -291,23 +299,23 @@ protected function createMultiselectFields(string $promptId, array $params): arr $required = ($params[3] ?? false) !== false; $defaultValue = $this->answers[$promptId] ?? null; $options = $params[1] ?? []; - + $fields = []; - - $fields[] = Placeholder::make($promptId . '_label') + + $fields[] = Placeholder::make($promptId.'_label') ->label($label) ->content(''); - + foreach ($options as $key => $optionLabel) { - $checkboxId = $promptId . '_' . $key; + $checkboxId = $promptId.'_'.$key; $isChecked = is_array($defaultValue) && in_array($key, $defaultValue); - + $fields[] = Checkbox::make($checkboxId) ->label($optionLabel) ->default($isChecked) ->live(onBlur: false); } - + return $fields; } @@ -317,7 +325,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $required = ($params[3] ?? false) !== false; $defaultValue = $this->answers[$promptId] ?? null; - return match($method) { + return match ($method) { 'text' => TextInput::make($promptId) ->label($label) ->placeholder($params[1] ?? '') diff --git a/packages/prompts/src/Filament/Pages/RunCommandPage.php b/packages/prompts/src/Filament/Pages/RunCommandPage.php index aad8e2937..7fbf2b83c 100644 --- a/packages/prompts/src/Filament/Pages/RunCommandPage.php +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -6,20 +6,22 @@ class RunCommandPage extends Page { - protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-command-line'; - + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-command-line'; + protected string $view = 'moox-prompts::filament.pages.run-command'; - + protected static ?string $navigationLabel = 'Command Runner'; - + protected static ?string $title = 'Command Runner'; - - protected static string | \UnitEnum | null $navigationGroup = 'System'; - + + protected static string|\UnitEnum|null $navigationGroup = 'System'; + protected static ?int $navigationSort = 100; public string $selectedCommand = ''; + public array $availableCommands = []; + public bool $started = false; public function mount(): void @@ -43,13 +45,13 @@ public function resetCommand(): void protected function getAvailableCommands(): array { $allowedCommands = config('prompts.allowed_commands', []); - + if (empty($allowedCommands)) { return []; } - + $allCommands = app(\Illuminate\Contracts\Console\Kernel::class)->all(); - + $available = []; foreach ($allowedCommands as $commandName) { if (isset($allCommands[$commandName])) { @@ -57,9 +59,9 @@ protected function getAvailableCommands(): array $available[$commandName] = $command->getDescription() ?: $commandName; } } - + ksort($available); - + return $available; } } diff --git a/packages/prompts/src/Filament/PromptsPlugin.php b/packages/prompts/src/Filament/PromptsPlugin.php index c6c362904..988a5558a 100644 --- a/packages/prompts/src/Filament/PromptsPlugin.php +++ b/packages/prompts/src/Filament/PromptsPlugin.php @@ -35,4 +35,3 @@ public static function get(): static return filament(app(static::class)->getId()); } } - diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index 82eae9c3e..33b45e292 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -4,8 +4,8 @@ use Moox\Core\MooxServiceProvider; use Moox\Prompts\Support\CliPromptRuntime; -use Moox\Prompts\Support\PromptRuntime; use Moox\Prompts\Support\PromptResponseStore; +use Moox\Prompts\Support\PromptRuntime; use Moox\Prompts\Support\WebPromptRuntime; use Spatie\LaravelPackageTools\Package; diff --git a/packages/prompts/src/Support/PendingPromptsException.php b/packages/prompts/src/Support/PendingPromptsException.php index 65c675ba1..584c261c8 100644 --- a/packages/prompts/src/Support/PendingPromptsException.php +++ b/packages/prompts/src/Support/PendingPromptsException.php @@ -24,4 +24,3 @@ public function getPrompt(): ?array return $this->prompts[0] ?? null; } } - diff --git a/packages/prompts/src/Support/PromptResponseStore.php b/packages/prompts/src/Support/PromptResponseStore.php index 750df4664..4f4f7569f 100644 --- a/packages/prompts/src/Support/PromptResponseStore.php +++ b/packages/prompts/src/Support/PromptResponseStore.php @@ -5,6 +5,7 @@ class PromptResponseStore { protected array $responses = []; + protected int $promptCounter = 0; public function set(string $promptId, mixed $value): void @@ -35,7 +36,7 @@ public function all(): array public function getNextPromptId(string $method): string { - return 'prompt_' . (++$this->promptCounter); + return 'prompt_'.(++$this->promptCounter); } public function resetCounter(): void diff --git a/packages/prompts/src/Support/WebPromptRuntime.php b/packages/prompts/src/Support/WebPromptRuntime.php index 9187b4afe..b4068f641 100644 --- a/packages/prompts/src/Support/WebPromptRuntime.php +++ b/packages/prompts/src/Support/WebPromptRuntime.php @@ -24,21 +24,24 @@ protected function checkOrThrow(string $promptId, array $promptData): mixed { if ($this->responseStore->has($promptId)) { $value = $this->responseStore->get($promptId); - + if ($promptData['method'] === 'multiselect') { - if (!is_array($value)) { + if (! is_array($value)) { if ($value === true) { $options = $promptData['params'][1] ?? []; + return array_keys($options); } if ($value !== null && $value !== '') { return [$value]; } + return []; } + return $value; } - + return $value; } @@ -59,7 +62,7 @@ public function text( callable|string|null $transform = null, ): string { $promptId = $this->generatePromptId('text'); - + return $this->checkOrThrow($promptId, [ 'method' => 'text', 'params' => [ @@ -83,7 +86,7 @@ public function textarea( callable|string|null $transform = null, ): string { $promptId = $this->generatePromptId('textarea'); - + return $this->checkOrThrow($promptId, [ 'method' => 'textarea', 'params' => [ @@ -106,7 +109,7 @@ public function password( callable|string|null $transform = null, ): string { $promptId = $this->generatePromptId('password'); - + return $this->checkOrThrow($promptId, [ 'method' => 'password', 'params' => [ @@ -129,7 +132,7 @@ public function confirm( string $hint = '', ): bool { $promptId = $this->generatePromptId('confirm'); - + return $this->checkOrThrow($promptId, [ 'method' => 'confirm', 'params' => [ @@ -153,7 +156,7 @@ public function select( callable|string|null $transform = null, ): mixed { $promptId = $this->generatePromptId('select'); - + return $this->checkOrThrow($promptId, [ 'method' => 'select', 'params' => [ @@ -179,7 +182,7 @@ public function multiselect( callable|string|null $transform = null, ): array { $promptId = $this->generatePromptId('multiselect'); - + return $this->checkOrThrow($promptId, [ 'method' => 'multiselect', 'params' => [ @@ -206,7 +209,7 @@ public function suggest( callable|string|null $transform = null, ): mixed { $promptId = $this->generatePromptId('suggest'); - + return $this->checkOrThrow($promptId, [ 'method' => 'suggest', 'params' => [ @@ -233,7 +236,7 @@ public function search( callable|string|null $transform = null, ): mixed { $promptId = $this->generatePromptId('search'); - + return $this->checkOrThrow($promptId, [ 'method' => 'search', 'params' => [ @@ -260,7 +263,7 @@ public function multisearch( callable|string|null $transform = null, ): array { $promptId = $this->generatePromptId('multisearch'); - + return $this->checkOrThrow($promptId, [ 'method' => 'multisearch', 'params' => [ @@ -276,41 +279,23 @@ public function multisearch( ]); } - public function pause(string $message = 'Press ENTER to continue'): void - { - } + public function pause(string $message = 'Press ENTER to continue'): void {} - public function note(string $message): void - { - } + public function note(string $message): void {} - public function info(string $message): void - { - } + public function info(string $message): void {} - public function warning(string $message): void - { - } + public function warning(string $message): void {} - public function error(string $message): void - { - } + public function error(string $message): void {} - public function alert(string $message): void - { - } + public function alert(string $message): void {} - public function intro(string $message): void - { - } + public function intro(string $message): void {} - public function outro(string $message): void - { - } + public function outro(string $message): void {} - public function table(array $headers, array $rows): void - { - } + public function table(array $headers, array $rows): void {} public function spin(Closure $callback, string $message = ''): mixed { @@ -337,9 +322,7 @@ public function progress( return $results; } - public function clear(): void - { - } + public function clear(): void {} public function form(): FormBuilder { From 3558a39116fd8736879103bd28542f5c73aa4525 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:33:24 +0100 Subject: [PATCH 03/16] wip --- .../src/Console/Commands/MooxInstaller.php | 4 +- .../filament/components/run-command.blade.php | 38 +++ .../Components/RunCommandComponent.php | 312 ++++++++++++++++-- .../prompts/src/Support/WebCommandRunner.php | 231 +++++++++++++ 4 files changed, 563 insertions(+), 22 deletions(-) create mode 100644 packages/prompts/src/Support/WebCommandRunner.php diff --git a/packages/core/src/Console/Commands/MooxInstaller.php b/packages/core/src/Console/Commands/MooxInstaller.php index 090d0ef54..c225d3832 100644 --- a/packages/core/src/Console/Commands/MooxInstaller.php +++ b/packages/core/src/Console/Commands/MooxInstaller.php @@ -13,8 +13,8 @@ use Moox\Core\Console\Traits\SelectFilamentPanel; use Moox\Core\Services\PackageService; -use function Laravel\Prompts\multiselect; -use function Laravel\Prompts\select; +use function Moox\Prompts\multiselect; +use function Moox\Prompts\select; class MooxInstaller extends Command { diff --git a/packages/prompts/resources/views/filament/components/run-command.blade.php b/packages/prompts/resources/views/filament/components/run-command.blade.php index 38b390337..a4378823f 100644 --- a/packages/prompts/resources/views/filament/components/run-command.blade.php +++ b/packages/prompts/resources/views/filament/components/run-command.blade.php @@ -5,6 +5,13 @@ Fehler

{{ $error }}

+ @if($output) +
{{ $output }}
+ @elseif($currentStepOutput) +
{{ $currentStepOutput }}
+ @endif @elseif($isComplete) @@ -18,11 +25,42 @@ @elseif($currentPrompt) {{ $this->form }} + + @if(!empty($validationErrors)) +
+
+ + + + + Validierungsfehler: +
+ +
+ @endif +
Weiter
+ + @if($currentStepOutput) + + + Command Output + +
{{ $currentStepOutput }}
+
+ @endif @else diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 1c709d0d9..2615c3ec2 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -11,11 +11,14 @@ use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Illuminate\Console\OutputStyle; +use Illuminate\Support\Facades\Validator; use Livewire\Component; use Moox\Prompts\Support\PendingPromptsException; use Moox\Prompts\Support\PromptResponseStore; +use Moox\Prompts\Support\WebCommandRunner; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use Throwable; class RunCommandComponent extends Component implements HasForms { @@ -29,14 +32,22 @@ class RunCommandComponent extends Component implements HasForms public string $output = ''; + public string $currentStepOutput = ''; + + public string $lastOutput = ''; + public bool $isComplete = false; + public array $validationErrors = []; + public ?string $error = null; public array $answers = []; public array $data = []; + public int $executionStep = 0; + protected PromptResponseStore $responseStore; public function boot(): void @@ -53,7 +64,13 @@ public function mount(string $command = '', array $commandInput = []): void $this->isComplete = false; $this->currentPrompt = null; $this->output = ''; + $this->currentStepOutput = ''; + $this->lastOutput = ''; + $this->validationErrors = []; + $this->executionOutputHashes = []; $this->error = null; + $this->commandStarted = false; + $this->executionStep = 0; $this->responseStore->resetCounter(); @@ -68,12 +85,20 @@ protected function runCommand(): void $this->isComplete = false; try { + // Stelle sicher, dass publishable Resources registriert sind (auch im Web-Kontext) + // Dies löst das Problem, dass Spatie Package Tools nur registriert, wenn runningInConsole() true ist + WebCommandRunner::ensurePublishableResourcesRegistered(); + $this->responseStore->resetCounter(); foreach ($this->answers as $promptId => $answer) { $this->responseStore->set($promptId, $answer); } + foreach ($this->answers as $promptId => $answer) { + $this->responseStore->set($promptId, $answer); + } + app()->instance('moox.prompts.response_store', $this->responseStore); $commandInstance = app(\Illuminate\Contracts\Console\Kernel::class) @@ -100,12 +125,35 @@ protected function runCommand(): void $output ); - $this->output = $output->fetch(); + $newOutput = $output->fetch(); + // Kumulativer Output: Füge neuen Output zum bestehenden hinzu + if (! empty($newOutput)) { + if (! empty($this->output)) { + $this->output .= "\n" . $newOutput; + } else { + $this->output = $newOutput; + } + } + // Am Ende: Zeige den vollständigen Output + $this->currentStepOutput = $this->output; $this->isComplete = true; $this->currentPrompt = null; } catch (PendingPromptsException $e) { + $newOutput = $output->fetch(); + // Kumulativer Output: Füge neuen Output zum bestehenden hinzu + if (! empty($newOutput)) { + if (! empty($this->output)) { + $this->output .= "\n" . $newOutput; + } else { + $this->output = $newOutput; + } + } + // Zeige kumulativen Output während Prompts für Debugging + $this->currentStepOutput = $this->output ?? ''; + $prompt = $e->getPrompt(); $this->currentPrompt = $prompt; + $this->executionStep++; $promptId = $prompt['id'] ?? null; if ($promptId && isset($this->answers[$promptId])) { @@ -124,12 +172,41 @@ protected function runCommand(): void $this->form->fill([$promptId => $value]); } } - } catch (\Exception $e) { - $this->error = $e->getMessage(); + } catch (Throwable $e) { + $newOutput = isset($output) ? $output->fetch() : ''; + // Kumulativer Output auch bei Exceptions + if (! empty($newOutput)) { + if (! empty($this->output)) { + $this->output .= "\n" . $newOutput; + } else { + $this->output = $newOutput; + } + } + $this->output = $this->appendExceptionToOutput($this->output, $e); + $this->currentStepOutput = $this->output; + $this->error = $this->formatThrowableMessage($e); $this->currentPrompt = null; } } + protected function formatThrowableMessage(Throwable $e): string + { + return sprintf( + '%s: %s in %s:%d', + $e::class, + $e->getMessage(), + $e->getFile(), + $e->getLine() + ); + } + + protected function appendExceptionToOutput(string $output, Throwable $e): string + { + $trace = $e->getTraceAsString(); + + return trim($output."\n\n".$this->formatThrowableMessage($e)."\n".$trace); + } + public function submitPrompt(): void { if (! $this->currentPrompt) { @@ -142,6 +219,8 @@ public function submitPrompt(): void } try { + $this->validationErrors = []; + if ($this->currentPrompt['method'] === 'multiselect') { $params = $this->currentPrompt['params'] ?? []; $options = $params[1] ?? []; @@ -206,23 +285,46 @@ public function submitPrompt(): void } } } catch (\Illuminate\Validation\ValidationException $e) { + // Capture Filament validation errors + $errors = $e->errors(); + $this->validationErrors = []; + + // Get errors for current prompt field + if (isset($errors[$promptId])) { + $this->validationErrors = is_array($errors[$promptId]) + ? $errors[$promptId] + : [$errors[$promptId]]; + } + + // If no specific field errors, get all errors + if (empty($this->validationErrors)) { + foreach ($errors as $fieldErrors) { + if (is_array($fieldErrors)) { + $this->validationErrors = array_merge($this->validationErrors, $fieldErrors); + } else { + $this->validationErrors[] = $fieldErrors; + } + } + } + return; } } - if (($answer === null || $answer === '') && $this->currentPrompt['method'] === 'select') { - $params = $this->currentPrompt['params'] ?? []; - $options = $params[1] ?? []; - if (! empty($options)) { - $answer = array_key_first($options); - } - } - + // Validate empty fields before returning if ($this->currentPrompt['method'] === 'confirm') { if ($answer === null) { + $this->validatePromptAnswer($promptId, null, $this->currentPrompt); + return; + } + } elseif ($this->currentPrompt['method'] === 'select') { + // Für Select: Prüfe ob leer oder null + if ($answer === null || $answer === '' || $answer === '0') { + $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); return; } } elseif ($answer === null || $answer === '') { + $this->validatePromptAnswer($promptId, '', $this->currentPrompt); return; } @@ -250,6 +352,11 @@ public function submitPrompt(): void } } + $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); + if (! empty($this->validationErrors)) { + return; + } + $this->answers[$promptId] = $answer; $this->currentPrompt = null; $this->runCommand(); @@ -258,6 +365,120 @@ public function submitPrompt(): void } } + protected function validatePromptAnswer(string $promptId, mixed $answer, array $prompt): void + { + $method = $prompt['method'] ?? ''; + $params = $prompt['params'] ?? []; + + $rules = []; + $messages = []; + + // Map required flag per method (parameter positions differ) + $requiredFlag = match ($method) { + 'text', 'textarea', 'password' => $params[3] ?? false, + 'multiselect' => $params[3] ?? false, + 'confirm' => $params[2] ?? false, + default => false, + }; + + // Multiselect: erzwinge Auswahl, wenn Optionen vorhanden + if ($method === 'multiselect' && ! empty($params[1] ?? [])) { + $requiredFlag = true; + } + + // Map validate parameter per method (avoid treating confirm/labels as rules) + $validate = match ($method) { + 'text', 'textarea', 'password' => $params[4] ?? null, + 'select' => $params[5] ?? null, + 'multiselect' => $params[6] ?? null, + default => null, + }; + + // Normalize rule strings (split pipe-delimited) + $pushRules = function (array &$into, string|array|null $value): void { + if ($value === null || $value === false || $value === '') { + return; + } + $items = is_array($value) ? $value : explode('|', $value); + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $into[] = $item; + } + } + }; + + $pushRules($rules, $requiredFlag ? 'required' : null); + + if ($method === 'multiselect') { + $rules[] = 'array'; + if ($requiredFlag !== false) { + $rules[] = 'min:1'; + } + } + + if ($method === 'confirm') { + $rules[] = 'boolean'; + } + + if ($method === 'select' && $requiredFlag !== false) { + $rules[] = 'in:'.implode(',', array_keys($params[1] ?? [])); + } + + $pushRules($rules, $validate); + + $callableErrors = []; + $validateCallable = null; + if (is_callable($validate)) { + $validateCallable = $validate; + } elseif (is_array($validate)) { + foreach ($validate as $item) { + if (is_callable($item)) { + $validateCallable = $item; + break; + } + } + } + + if ($validateCallable) { + $result = $validateCallable($answer); + if (is_string($result) && $result !== '') { + $callableErrors[] = $result; + } + if ($result === false) { + $callableErrors[] = 'Ungültiger Wert.'; + } + } + + if (! empty($rules)) { + // Freundlichere Meldungen speziell für Multiselect + if ($method === 'multiselect') { + $messages["{$promptId}.required"] = 'Bitte mindestens eine Option wählen.'; + $messages["{$promptId}.min"] = 'Bitte mindestens eine Option wählen.'; + } + + // Freundlichere Meldungen speziell für Select + if ($method === 'select') { + $messages["{$promptId}.required"] = 'Bitte wählen Sie eine Option aus.'; + $messages["{$promptId}.in"] = 'Bitte wählen Sie eine gültige Option aus.'; + } + + $validator = Validator::make( + [$promptId => $answer], + [$promptId => $rules], + $messages + ); + + if ($validator->fails()) { + $this->validationErrors = $validator->errors()->all(); + } + } + + if (! empty($callableErrors)) { + $this->validationErrors = array_merge($this->validationErrors, $callableErrors); + } + } + protected function getForms(): array { return ['form']; @@ -322,15 +543,65 @@ protected function createMultiselectFields(string $promptId, array $params): arr protected function createFieldFromPrompt(string $promptId, string $method, array $params): ?\Filament\Forms\Components\Field { $label = $params[0] ?? ''; - $required = ($params[3] ?? false) !== false; + // Determine required flag per prompt type (indexes differ) + $required = match ($method) { + 'text', 'textarea', 'password' => ($params[3] ?? false) !== false, + 'multiselect' => ($params[3] ?? false) !== false, + 'confirm' => ($params[2] ?? false) !== false, + 'select' => ($params[2] ?? null) === null, // Required wenn kein Default gesetzt + default => false, + }; $defaultValue = $this->answers[$promptId] ?? null; + $options = $params[1] ?? []; + $defaultSelect = $defaultValue ?? ($params[2] ?? null); + $confirmDefault = $defaultValue ?? ($params[1] ?? false); + + // Map validate parameter per method + $validate = match ($method) { + 'text', 'textarea', 'password' => $params[4] ?? null, + 'select' => $params[5] ?? null, + 'multiselect' => $params[6] ?? null, + default => null, + }; + + // Build validation rules for Filament fields (normalize pipe-delimited) + $rules = []; + $pushRules = function (array &$into, string|array|null $value): void { + if ($value === null || $value === false || $value === '') { + return; + } + $items = is_array($value) ? $value : explode('|', $value); + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $into[] = $item; + } + } + }; + + if ($required) { + $rules[] = 'required'; + } + + // Add method-specific rules + if ($method === 'select' && $required && ! empty($options)) { + $rules[] = 'in:'.implode(',', array_keys($options)); + } + + if ($method === 'confirm') { + $rules[] = 'boolean'; + } + + $pushRules($rules, $validate); + + // Kein automatisches Default für Select - Validation wird verwendet return match ($method) { 'text' => TextInput::make($promptId) ->label($label) ->placeholder($params[1] ?? '') ->default($defaultValue ?? $params[2] ?? '') - ->required($required) + ->rules($rules) ->hint($params[6] ?? null) ->live(onBlur: false), @@ -338,7 +609,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array ->label($label) ->placeholder($params[1] ?? '') ->default($defaultValue ?? $params[2] ?? '') - ->required($required) + ->rules($rules) ->rows(5) ->hint($params[6] ?? null), @@ -347,15 +618,16 @@ protected function createFieldFromPrompt(string $promptId, string $method, array ->password() ->placeholder($params[1] ?? '') ->default($defaultValue ?? '') - ->required($required) + ->rules($rules) ->hint($params[6] ?? null) ->live(onBlur: false), 'select' => Select::make($promptId) ->label($label) - ->options($params[1] ?? []) - ->default($defaultValue ?? $params[2] ?? null) - ->required($required) + ->options($options) + ->default($defaultSelect !== null ? $defaultSelect : null) + ->rules($rules) + ->placeholder('Bitte wählen...') ->hint($params[4] ?? null) ->live(onBlur: false), @@ -367,8 +639,8 @@ protected function createFieldFromPrompt(string $promptId, string $method, array true => 'Ja', false => 'Nein', ]) - ->default($defaultValue ?? $params[1] ?? false) - ->required($required) + ->default($confirmDefault) + ->rules($rules) ->hint($params[6] ?? null) ->live(onBlur: false), diff --git a/packages/prompts/src/Support/WebCommandRunner.php b/packages/prompts/src/Support/WebCommandRunner.php new file mode 100644 index 000000000..1237335e0 --- /dev/null +++ b/packages/prompts/src/Support/WebCommandRunner.php @@ -0,0 +1,231 @@ +getLoadedProviders(); + + foreach ($loadedProviders as $providerClass => $loaded) { + if (! $loaded || ! class_exists($providerClass)) { + continue; + } + + try { + $provider = static::getProviderInstance($app, $providerClass); + + if (! $provider instanceof PackageServiceProvider) { + continue; + } + + $package = static::getPackage($provider); + if (! $package) { + continue; + } + + static::registerAllPublishes($provider, $package); + } catch (\Exception $e) { + continue; + } + } + } + + protected static function getProviderInstance($app, string $providerClass) + { + $reflection = new \ReflectionClass($app); + $property = $reflection->getProperty('serviceProviders'); + $property->setAccessible(true); + $providers = $property->getValue($app); + + return $providers[$providerClass] ?? null; + } + + protected static function getPackage(PackageServiceProvider $provider) + { + $reflection = new \ReflectionClass($provider); + + if (! $reflection->hasProperty('package')) { + return null; + } + + $property = $reflection->getProperty('package'); + $property->setAccessible(true); + + if (! $property->isInitialized($provider)) { + return null; + } + + return $property->getValue($provider); + } + + protected static function registerAllPublishes(PackageServiceProvider $provider, $package): void + { + static::registerConfigPublishes($provider, $package); + static::registerMigrationPublishes($provider, $package); + static::registerViewPublishes($provider, $package); + static::registerTranslationPublishes($provider, $package); + static::registerAssetPublishes($provider, $package); + } + + protected static function registerConfigPublishes(PackageServiceProvider $provider, $package): void + { + $configFileNames = static::getPackageProperty($package, 'configFileNames'); + if (empty($configFileNames)) { + return; + } + + $basePathMethod = static::getPackageMethod($package, 'basePath'); + $shortName = static::invokePackageMethod($package, 'shortName'); + + foreach ($configFileNames as $configFileName) { + $vendorConfig = $basePathMethod("/../config/{$configFileName}.php"); + + if (! is_file($vendorConfig)) { + $vendorConfig = $basePathMethod("/../config/{$configFileName}.php.stub"); + if (! is_file($vendorConfig)) { + continue; + } + } + + static::callPublishes($provider, [ + $vendorConfig => config_path("{$configFileName}.php") + ], "{$shortName}-config"); + } + } + + protected static function registerMigrationPublishes(PackageServiceProvider $provider, $package): void + { + $migrationFileNames = static::getPackageProperty($package, 'migrationFileNames'); + if (empty($migrationFileNames)) { + return; + } + + $basePathMethod = static::getPackageMethod($package, 'basePath'); + $shortName = static::invokePackageMethod($package, 'shortName'); + + foreach ($migrationFileNames as $migrationFileName) { + $vendorMigration = $basePathMethod("/../database/migrations/{$migrationFileName}.php"); + if (! is_file($vendorMigration)) { + $vendorMigration = $basePathMethod("/../database/migrations/{$migrationFileName}.php.stub"); + if (! is_file($vendorMigration)) { + continue; + } + } + + static::callPublishes($provider, [ + $vendorMigration => database_path("migrations/{$migrationFileName}.php") + ], "{$shortName}-migrations"); + } + } + + protected static function registerViewPublishes(PackageServiceProvider $provider, $package): void + { + if (! static::getPackageProperty($package, 'hasViews')) { + return; + } + + $basePathMethod = static::getPackageMethod($package, 'basePath'); + $shortName = static::invokePackageMethod($package, 'shortName'); + $viewsPath = $basePathMethod('/../resources/views'); + + if (! is_dir($viewsPath)) { + return; + } + + static::callPublishes($provider, [ + $viewsPath => base_path("resources/views/vendor/{$shortName}") + ], "{$shortName}-views"); + } + + protected static function registerTranslationPublishes(PackageServiceProvider $provider, $package): void + { + if (! static::getPackageProperty($package, 'hasTranslations')) { + return; + } + + $basePathMethod = static::getPackageMethod($package, 'basePath'); + $shortName = static::invokePackageMethod($package, 'shortName'); + $vendorTranslations = $basePathMethod('/../resources/lang'); + + if (! is_dir($vendorTranslations)) { + return; + } + + $appTranslations = function_exists('lang_path') + ? lang_path("vendor/{$shortName}") + : resource_path("lang/vendor/{$shortName}"); + + static::callPublishes($provider, [ + $vendorTranslations => $appTranslations + ], "{$shortName}-translations"); + } + + protected static function registerAssetPublishes(PackageServiceProvider $provider, $package): void + { + if (! static::getPackageProperty($package, 'hasAssets')) { + return; + } + + $basePathMethod = static::getPackageMethod($package, 'basePath'); + $shortName = static::invokePackageMethod($package, 'shortName'); + $vendorAssets = $basePathMethod('/../resources/dist'); + + if (! is_dir($vendorAssets)) { + return; + } + + static::callPublishes($provider, [ + $vendorAssets => public_path("vendor/{$shortName}") + ], "{$shortName}-assets"); + } + + protected static function getPackageProperty($package, string $property) + { + $reflection = new \ReflectionClass($package); + + if (! $reflection->hasProperty($property)) { + return null; + } + + $prop = $reflection->getProperty($property); + $prop->setAccessible(true); + + return $prop->getValue($package); + } + + protected static function getPackageMethod($package, string $method): callable + { + $reflection = new \ReflectionClass($package); + $methodRef = $reflection->getMethod($method); + $methodRef->setAccessible(true); + + return fn (...$args) => $methodRef->invoke($package, ...$args); + } + + protected static function invokePackageMethod($package, string $method) + { + $reflection = new \ReflectionClass($package); + $methodRef = $reflection->getMethod($method); + $methodRef->setAccessible(true); + + return $methodRef->invoke($package); + } + + protected static function callPublishes(PackageServiceProvider $provider, array $paths, ?string $group = null): void + { + $reflection = new \ReflectionClass($provider); + $method = $reflection->getMethod('publishes'); + $method->setAccessible(true); + $method->invoke($provider, $paths, $group); + } +} \ No newline at end of file From fba785a973bc88c9989133cb94cbacd8649c757c Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:34:08 +0000 Subject: [PATCH 04/16] Fix styling --- .../Components/RunCommandComponent.php | 31 ++++++++++--------- .../prompts/src/Support/WebCommandRunner.php | 24 +++++++------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 2615c3ec2..91a3ef580 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -88,7 +88,7 @@ protected function runCommand(): void // Stelle sicher, dass publishable Resources registriert sind (auch im Web-Kontext) // Dies löst das Problem, dass Spatie Package Tools nur registriert, wenn runningInConsole() true ist WebCommandRunner::ensurePublishableResourcesRegistered(); - + $this->responseStore->resetCounter(); foreach ($this->answers as $promptId => $answer) { @@ -129,7 +129,7 @@ protected function runCommand(): void // Kumulativer Output: Füge neuen Output zum bestehenden hinzu if (! empty($newOutput)) { if (! empty($this->output)) { - $this->output .= "\n" . $newOutput; + $this->output .= "\n".$newOutput; } else { $this->output = $newOutput; } @@ -143,14 +143,14 @@ protected function runCommand(): void // Kumulativer Output: Füge neuen Output zum bestehenden hinzu if (! empty($newOutput)) { if (! empty($this->output)) { - $this->output .= "\n" . $newOutput; + $this->output .= "\n".$newOutput; } else { $this->output = $newOutput; } } // Zeige kumulativen Output während Prompts für Debugging $this->currentStepOutput = $this->output ?? ''; - + $prompt = $e->getPrompt(); $this->currentPrompt = $prompt; $this->executionStep++; @@ -177,7 +177,7 @@ protected function runCommand(): void // Kumulativer Output auch bei Exceptions if (! empty($newOutput)) { if (! empty($this->output)) { - $this->output .= "\n" . $newOutput; + $this->output .= "\n".$newOutput; } else { $this->output = $newOutput; } @@ -288,14 +288,14 @@ public function submitPrompt(): void // Capture Filament validation errors $errors = $e->errors(); $this->validationErrors = []; - + // Get errors for current prompt field if (isset($errors[$promptId])) { - $this->validationErrors = is_array($errors[$promptId]) - ? $errors[$promptId] + $this->validationErrors = is_array($errors[$promptId]) + ? $errors[$promptId] : [$errors[$promptId]]; } - + // If no specific field errors, get all errors if (empty($this->validationErrors)) { foreach ($errors as $fieldErrors) { @@ -306,7 +306,7 @@ public function submitPrompt(): void } } } - + return; } } @@ -315,16 +315,19 @@ public function submitPrompt(): void if ($this->currentPrompt['method'] === 'confirm') { if ($answer === null) { $this->validatePromptAnswer($promptId, null, $this->currentPrompt); + return; } } elseif ($this->currentPrompt['method'] === 'select') { // Für Select: Prüfe ob leer oder null if ($answer === null || $answer === '' || $answer === '0') { $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); + return; } } elseif ($answer === null || $answer === '') { $this->validatePromptAnswer($promptId, '', $this->currentPrompt); + return; } @@ -456,7 +459,7 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ $messages["{$promptId}.required"] = 'Bitte mindestens eine Option wählen.'; $messages["{$promptId}.min"] = 'Bitte mindestens eine Option wählen.'; } - + // Freundlichere Meldungen speziell für Select if ($method === 'select') { $messages["{$promptId}.required"] = 'Bitte wählen Sie eine Option aus.'; @@ -582,16 +585,16 @@ protected function createFieldFromPrompt(string $promptId, string $method, array if ($required) { $rules[] = 'required'; } - + // Add method-specific rules if ($method === 'select' && $required && ! empty($options)) { $rules[] = 'in:'.implode(',', array_keys($options)); } - + if ($method === 'confirm') { $rules[] = 'boolean'; } - + $pushRules($rules, $validate); // Kein automatisches Default für Select - Validation wird verwendet diff --git a/packages/prompts/src/Support/WebCommandRunner.php b/packages/prompts/src/Support/WebCommandRunner.php index 1237335e0..e135f3a8b 100644 --- a/packages/prompts/src/Support/WebCommandRunner.php +++ b/packages/prompts/src/Support/WebCommandRunner.php @@ -53,7 +53,7 @@ protected static function getProviderInstance($app, string $providerClass) protected static function getPackage(PackageServiceProvider $provider) { $reflection = new \ReflectionClass($provider); - + if (! $reflection->hasProperty('package')) { return null; } @@ -89,7 +89,7 @@ protected static function registerConfigPublishes(PackageServiceProvider $provid foreach ($configFileNames as $configFileName) { $vendorConfig = $basePathMethod("/../config/{$configFileName}.php"); - + if (! is_file($vendorConfig)) { $vendorConfig = $basePathMethod("/../config/{$configFileName}.php.stub"); if (! is_file($vendorConfig)) { @@ -98,7 +98,7 @@ protected static function registerConfigPublishes(PackageServiceProvider $provid } static::callPublishes($provider, [ - $vendorConfig => config_path("{$configFileName}.php") + $vendorConfig => config_path("{$configFileName}.php"), ], "{$shortName}-config"); } } @@ -123,7 +123,7 @@ protected static function registerMigrationPublishes(PackageServiceProvider $pro } static::callPublishes($provider, [ - $vendorMigration => database_path("migrations/{$migrationFileName}.php") + $vendorMigration => database_path("migrations/{$migrationFileName}.php"), ], "{$shortName}-migrations"); } } @@ -143,7 +143,7 @@ protected static function registerViewPublishes(PackageServiceProvider $provider } static::callPublishes($provider, [ - $viewsPath => base_path("resources/views/vendor/{$shortName}") + $viewsPath => base_path("resources/views/vendor/{$shortName}"), ], "{$shortName}-views"); } @@ -166,7 +166,7 @@ protected static function registerTranslationPublishes(PackageServiceProvider $p : resource_path("lang/vendor/{$shortName}"); static::callPublishes($provider, [ - $vendorTranslations => $appTranslations + $vendorTranslations => $appTranslations, ], "{$shortName}-translations"); } @@ -185,21 +185,21 @@ protected static function registerAssetPublishes(PackageServiceProvider $provide } static::callPublishes($provider, [ - $vendorAssets => public_path("vendor/{$shortName}") + $vendorAssets => public_path("vendor/{$shortName}"), ], "{$shortName}-assets"); } protected static function getPackageProperty($package, string $property) { $reflection = new \ReflectionClass($package); - + if (! $reflection->hasProperty($property)) { return null; } $prop = $reflection->getProperty($property); $prop->setAccessible(true); - + return $prop->getValue($package); } @@ -208,7 +208,7 @@ protected static function getPackageMethod($package, string $method): callable $reflection = new \ReflectionClass($package); $methodRef = $reflection->getMethod($method); $methodRef->setAccessible(true); - + return fn (...$args) => $methodRef->invoke($package, ...$args); } @@ -217,7 +217,7 @@ protected static function invokePackageMethod($package, string $method) $reflection = new \ReflectionClass($package); $methodRef = $reflection->getMethod($method); $methodRef->setAccessible(true); - + return $methodRef->invoke($package); } @@ -228,4 +228,4 @@ protected static function callPublishes(PackageServiceProvider $provider, array $method->setAccessible(true); $method->invoke($provider, $paths, $group); } -} \ No newline at end of file +} From 611ba7be0a40f12d8ae5487bdd3de4a5eac64bad Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:01:35 +0100 Subject: [PATCH 05/16] wip --- packages/prompts/README.md | 165 +++++++++----- packages/prompts/config/prompts.php | 3 +- .../filament/pages/run-command.blade.php | 91 +++++--- .../Components/RunCommandComponent.php | 195 ++++++++-------- .../prompts/src/PromptsServiceProvider.php | 25 +++ .../prompts/src/Support/CliPromptRuntime.php | 48 ---- packages/prompts/src/Support/FlowCommand.php | 39 ++++ .../prompts/src/Support/PromptFlowRunner.php | 209 ++++++++++++++++++ .../prompts/src/Support/PromptFlowState.php | 43 ++++ .../src/Support/PromptFlowStateStore.php | 45 ++++ .../prompts/src/Support/PromptRuntime.php | 36 --- .../prompts/src/Support/WebPromptRuntime.php | 15 ++ packages/prompts/src/functions.php | 48 ---- 13 files changed, 637 insertions(+), 325 deletions(-) create mode 100644 packages/prompts/src/Support/FlowCommand.php create mode 100644 packages/prompts/src/Support/PromptFlowRunner.php create mode 100644 packages/prompts/src/Support/PromptFlowState.php create mode 100644 packages/prompts/src/Support/PromptFlowStateStore.php diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 2507e6b79..29f933502 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -1,59 +1,116 @@ # Moox Prompts -CLI-kompatible Prompts für Laravel Artisan Commands. - -## Übersicht - -Dieses Package bietet eine einfache Proxy-Implementierung für Laravel Prompts. Es ermöglicht es, die gleichen Helper-Funktionen wie Laravel Prompts zu verwenden, mit der Möglichkeit, später Web-Funktionalität hinzuzufügen. - -## Features - -- ✅ Alle Laravel Prompt-Typen unterstützt (`text`, `select`, `multiselect`, `confirm`, etc.) -- ✅ Identische API wie Laravel Prompts - -## Installation - -```bash -composer require moox/prompts -``` - -## Verwendung - -### In Commands - -Verwende die gleichen Helper-Funktionen wie in Laravel Prompts: - -```php -use function Moox\Prompts\text; -use function Moox\Prompts\select; -use function Moox\Prompts\confirm; -use function Moox\Prompts\form; - -public function handle() -{ - // Einzelne Prompts - $name = text('What is your name?'); - $package = select('Which package?', ['moox/core', 'moox/user']); - $confirm = confirm('Are you sure?'); - - // FormBuilder - $result = form() - ->text('Name?') - ->select('Package?', ['moox/core', 'moox/user']) - ->submit(); - - // Command-Logik... -} -``` - -## Architektur - -Das Package besteht aus: - -- **PromptRuntime**: Interface für Prompt-Implementierungen -- **CliPromptRuntime**: CLI-Implementierung (delegiert an Laravel Prompts) -- **functions.php**: Globale Helper-Funktionen -- **PromptsServiceProvider**: Registriert Services +CLI- und Web-kompatible Prompts für Laravel Artisan Commands – mit einem Flow, der im Browser Schritt für Schritt weiterläuft. + +## Wie muss ein Flow-Command aussehen? + +Damit ein Command sowohl in der CLI als auch im Web korrekt als Flow funktioniert, müssen nur diese Regeln erfüllt sein: + +- **Von `FlowCommand` erben** + ```php + use Moox\Prompts\Support\FlowCommand; + use function Moox\Prompts\text; + use function Moox\Prompts\select; + + class ProjectSetupCommand extends FlowCommand + { + protected $signature = 'prompts:project-setup'; + protected $description = 'Projekt Setup Wizard (CLI & Web)'; + ``` + +- **State als Properties ablegen** (werden im Web automatisch zwischen Steps gespeichert) + ```php + public ?string $environment = null; + public ?string $projectName = null; + ``` + +- **Steps über `promptFlowSteps()` definieren** – Reihenfolge = Flow-Reihenfolge + ```php + public function promptFlowSteps(): array + { + return [ + 'stepIntro', + 'stepEnvironment', + 'stepProjectName', + 'stepSummary', + ]; + } + ``` + +- **Jeder Step ist eine `public function stepXyz(): void`** – idealerweise **ein Prompt pro Step** + ```php + public function stepIntro(): void + { + $this->info('=== Projekt Setup ==='); + } + + public function stepEnvironment(): void + { + $this->environment = select( + label: 'Welche Umgebung konfigurierst du?', + options: [ + 'local' => 'Local', + 'staging' => 'Staging', + 'production' => 'Production', + ], + default: 'local', + ); + } + + public function stepProjectName(): void + { + $this->projectName = text( + label: 'Wie heißt dein Projekt?', + placeholder: 'z.B. MyCoolApp', + validate: 'required|min:3', + required: true, + ); + } + + public function stepSummary(): void + { + $this->info('--- Zusammenfassung ---'); + $this->line('Projekt: '.$this->projectName); + $this->line('Environment: '.$this->environment); + } + } + ``` + +- **Optionale Steps** kannst du einfach mit einem Guard am Anfang überspringen: + ```php + public array $features = []; + + public function stepLoggingLevel(): void + { + if (! in_array('logging', $this->features, true)) { + return; // Step wird übersprungen + } + + // Prompt … + } + ``` + +- **Andere Artisan-Commands aufrufen** – verwende im Flow immer `$this->call()` statt `Artisan::call()`, damit der Output auch im Web angezeigt wird: + ```php + public function stepPublishConfig(): void + { + $shouldPublish = confirm( + label: 'Möchtest du die Config jetzt veröffentlichen?', + default: true, + ); + + if (! $shouldPublish) { + return; + } + + $this->call('vendor:publish', [ + '--tag' => 'moox-prompts-config', + ]); + } + ``` + +Mehr ist im Command nicht nötig – keine speziellen Flow-Methoden, keine eigene Persistenz. +Der Rest (CLI/Web-Unterschied, State, Web-Oberfläche) wird komplett vom Package übernommen. ## License diff --git a/packages/prompts/config/prompts.php b/packages/prompts/config/prompts.php index 848515a16..84670b0ca 100644 --- a/packages/prompts/config/prompts.php +++ b/packages/prompts/config/prompts.php @@ -30,8 +30,7 @@ */ 'allowed_commands' => [ - 'prompts:test-flow', - 'prompts:publish-news-config', + 'prompts:project-setup', // Add more commands here as needed ], diff --git a/packages/prompts/resources/views/filament/pages/run-command.blade.php b/packages/prompts/resources/views/filament/pages/run-command.blade.php index 90ca78c7e..c1aa74833 100644 --- a/packages/prompts/resources/views/filament/pages/run-command.blade.php +++ b/packages/prompts/resources/views/filament/pages/run-command.blade.php @@ -1,41 +1,60 @@ - @if(!$started) - - - Command auswählen - - - @if(empty($availableCommands)) -

Keine Commands verfügbar. Bitte konfigurieren Sie die erlaubten Commands in der Konfiguration.

- @else -
- - - - @foreach($availableCommands as $commandName => $description) - - @endforeach - - - -
- - Command starten - -
-
- @endif -
+ @if (!$started) +
+ + @if (empty($availableCommands)) +

+ Keine Commands verfügbar. Bitte konfiguriere die erlaubten Commands in der + + config/prompts.php + . +

+ @else +
+
+ + + + + + @foreach ($availableCommands as $commandName => $description) + + @endforeach + + +
+ +
+

+ Nur Commands aus der Konfiguration sind hier sichtbar. +

+ + + Command starten + +
+
+ @endif +
+
@else - @livewire('moox-prompts.filament.components.run-command-component', [ - 'command' => $selectedCommand, - 'commandInput' => [] - ], key('run-command-' . $selectedCommand)) - -
- - Zurück - +
+ + @livewire('moox-prompts.filament.components.run-command-component', [ + 'command' => $selectedCommand, + 'commandInput' => [], + ], key('run-command-' . $selectedCommand)) + + +
+ + Zurück zur Command-Auswahl + +
@endif diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 91a3ef580..e69808f39 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -10,14 +10,14 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; -use Illuminate\Console\OutputStyle; use Illuminate\Support\Facades\Validator; use Livewire\Component; -use Moox\Prompts\Support\PendingPromptsException; +use Moox\Prompts\Support\PromptFlowRunner; +use Moox\Prompts\Support\PromptFlowStateStore; use Moox\Prompts\Support\PromptResponseStore; +use Moox\Prompts\Support\PromptRuntime; +use Moox\Prompts\Support\WebPromptRuntime; use Moox\Prompts\Support\WebCommandRunner; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; use Throwable; class RunCommandComponent extends Component implements HasForms @@ -48,6 +48,8 @@ class RunCommandComponent extends Component implements HasForms public int $executionStep = 0; + public ?string $flowId = null; + protected PromptResponseStore $responseStore; public function boot(): void @@ -71,8 +73,9 @@ public function mount(string $command = '', array $commandInput = []): void $this->error = null; $this->commandStarted = false; $this->executionStep = 0; + $this->flowId = null; - $this->responseStore->resetCounter(); + $this->responseStore->clear(); if ($command) { $this->runCommand(); @@ -85,103 +88,72 @@ protected function runCommand(): void $this->isComplete = false; try { - // Stelle sicher, dass publishable Resources registriert sind (auch im Web-Kontext) - // Dies löst das Problem, dass Spatie Package Tools nur registriert, wenn runningInConsole() true ist + // erzwinge Web-Runtime (nicht CLI) im Web-Kontext + app()->instance(PromptRuntime::class, new WebPromptRuntime); WebCommandRunner::ensurePublishableResourcesRegistered(); - $this->responseStore->resetCounter(); + $runner = app(PromptFlowRunner::class); + $stateStore = app(PromptFlowStateStore::class); - foreach ($this->answers as $promptId => $answer) { - $this->responseStore->set($promptId, $answer); + $state = $this->flowId ? $stateStore->get($this->flowId) : null; + if (! $state) { + // frischer Flow: ResponseStore und lokale States leeren + $this->responseStore->clear(); + $this->responseStore->resetCounter(); + $this->answers = []; + $this->data = []; + $this->currentPrompt = null; + $this->isComplete = false; + $this->error = null; + $this->output = ''; + $this->currentStepOutput = ''; + $state = $runner->start($this->command, $this->commandInput); + $this->flowId = $state->flowId; } + // Antworten in den ResponseStore spiegeln (ohne den Zähler zu manipulieren) foreach ($this->answers as $promptId => $answer) { $this->responseStore->set($promptId, $answer); } app()->instance('moox.prompts.response_store', $this->responseStore); - $commandInstance = app(\Illuminate\Contracts\Console\Kernel::class) - ->all()[$this->command] ?? null; + while (true) { + $result = $runner->runNext($state, $this->commandInput, $this->responseStore); + $this->appendOutput($result['output'] ?? ''); + $state = $result['state']; - if (! $commandInstance) { - $this->error = "Command nicht gefunden: {$this->command}"; + if (! empty($result['prompt'])) { + $this->currentStepOutput = $this->output ?? ''; + $this->currentPrompt = $result['prompt']; + $this->executionStep++; + $this->prefillPromptForm($result['prompt']); - return; - } + return; + } - $commandInstance->setLaravel(app()); - $output = new BufferedOutput; + if (! empty($result['failed'])) { + $this->currentStepOutput = $this->output; + $this->error = $result['error'] ?? 'Unbekannter Fehler'; + $this->currentPrompt = null; - $outputStyle = new OutputStyle( - new ArrayInput($this->commandInput), - $output - ); - $commandInstance->setOutput($outputStyle); - - try { - $commandInstance->run( - new ArrayInput($this->commandInput), - $output - ); - - $newOutput = $output->fetch(); - // Kumulativer Output: Füge neuen Output zum bestehenden hinzu - if (! empty($newOutput)) { - if (! empty($this->output)) { - $this->output .= "\n".$newOutput; - } else { - $this->output = $newOutput; - } - } - // Am Ende: Zeige den vollständigen Output - $this->currentStepOutput = $this->output; - $this->isComplete = true; - $this->currentPrompt = null; - } catch (PendingPromptsException $e) { - $newOutput = $output->fetch(); - // Kumulativer Output: Füge neuen Output zum bestehenden hinzu - if (! empty($newOutput)) { - if (! empty($this->output)) { - $this->output .= "\n".$newOutput; - } else { - $this->output = $newOutput; - } + return; } - // Zeige kumulativen Output während Prompts für Debugging - $this->currentStepOutput = $this->output ?? ''; - - $prompt = $e->getPrompt(); - $this->currentPrompt = $prompt; - $this->executionStep++; - - $promptId = $prompt['id'] ?? null; - if ($promptId && isset($this->answers[$promptId])) { - $value = $this->answers[$promptId]; - if ($prompt['method'] === 'multiselect') { - if (! is_array($value)) { - if ($value === true) { - $params = $prompt['params'] ?? []; - $options = $params[1] ?? []; - $value = array_keys($options); - } else { - $value = []; - } - } - } - $this->form->fill([$promptId => $value]); + + if (! empty($result['completed'])) { + $this->currentStepOutput = $this->output; + $this->isComplete = true; + $this->currentPrompt = null; + $this->responseStore->clear(); + $this->responseStore->resetCounter(); + $this->answers = []; + $this->data = []; + $this->flowId = null; + + return; } } } catch (Throwable $e) { - $newOutput = isset($output) ? $output->fetch() : ''; - // Kumulativer Output auch bei Exceptions - if (! empty($newOutput)) { - if (! empty($this->output)) { - $this->output .= "\n".$newOutput; - } else { - $this->output = $newOutput; - } - } $this->output = $this->appendExceptionToOutput($this->output, $e); $this->currentStepOutput = $this->output; $this->error = $this->formatThrowableMessage($e); @@ -189,6 +161,42 @@ protected function runCommand(): void } } + protected function appendOutput(?string $newOutput): void + { + if (empty($newOutput)) { + return; + } + + if (! empty($this->output)) { + $this->output .= "\n".$newOutput; + } else { + $this->output = $newOutput; + } + } + + protected function prefillPromptForm(array $prompt): void + { + $promptId = $prompt['id'] ?? null; + if (! $promptId || ! isset($this->answers[$promptId])) { + return; + } + + $value = $this->answers[$promptId]; + if (($prompt['method'] ?? '') === 'multiselect') { + if (! is_array($value)) { + if ($value === true) { + $params = $prompt['params'] ?? []; + $options = $params[1] ?? []; + $value = array_keys($options); + } else { + $value = []; + } + } + } + + $this->form->fill([$promptId => $value]); + } + protected function formatThrowableMessage(Throwable $e): string { return sprintf( @@ -285,18 +293,15 @@ public function submitPrompt(): void } } } catch (\Illuminate\Validation\ValidationException $e) { - // Capture Filament validation errors $errors = $e->errors(); $this->validationErrors = []; - // Get errors for current prompt field if (isset($errors[$promptId])) { $this->validationErrors = is_array($errors[$promptId]) ? $errors[$promptId] : [$errors[$promptId]]; } - // If no specific field errors, get all errors if (empty($this->validationErrors)) { foreach ($errors as $fieldErrors) { if (is_array($fieldErrors)) { @@ -311,7 +316,6 @@ public function submitPrompt(): void } } - // Validate empty fields before returning if ($this->currentPrompt['method'] === 'confirm') { if ($answer === null) { $this->validatePromptAnswer($promptId, null, $this->currentPrompt); @@ -319,7 +323,6 @@ public function submitPrompt(): void return; } } elseif ($this->currentPrompt['method'] === 'select') { - // Für Select: Prüfe ob leer oder null if ($answer === null || $answer === '' || $answer === '0') { $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); @@ -361,6 +364,7 @@ public function submitPrompt(): void } $this->answers[$promptId] = $answer; + $this->responseStore->set($promptId, $answer); $this->currentPrompt = null; $this->runCommand(); } catch (\Exception $e) { @@ -376,7 +380,6 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ $rules = []; $messages = []; - // Map required flag per method (parameter positions differ) $requiredFlag = match ($method) { 'text', 'textarea', 'password' => $params[3] ?? false, 'multiselect' => $params[3] ?? false, @@ -384,12 +387,10 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ default => false, }; - // Multiselect: erzwinge Auswahl, wenn Optionen vorhanden if ($method === 'multiselect' && ! empty($params[1] ?? [])) { $requiredFlag = true; } - // Map validate parameter per method (avoid treating confirm/labels as rules) $validate = match ($method) { 'text', 'textarea', 'password' => $params[4] ?? null, 'select' => $params[5] ?? null, @@ -397,7 +398,6 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ default => null, }; - // Normalize rule strings (split pipe-delimited) $pushRules = function (array &$into, string|array|null $value): void { if ($value === null || $value === false || $value === '') { return; @@ -454,13 +454,11 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ } if (! empty($rules)) { - // Freundlichere Meldungen speziell für Multiselect if ($method === 'multiselect') { $messages["{$promptId}.required"] = 'Bitte mindestens eine Option wählen.'; $messages["{$promptId}.min"] = 'Bitte mindestens eine Option wählen.'; } - // Freundlichere Meldungen speziell für Select if ($method === 'select') { $messages["{$promptId}.required"] = 'Bitte wählen Sie eine Option aus.'; $messages["{$promptId}.in"] = 'Bitte wählen Sie eine gültige Option aus.'; @@ -546,12 +544,11 @@ protected function createMultiselectFields(string $promptId, array $params): arr protected function createFieldFromPrompt(string $promptId, string $method, array $params): ?\Filament\Forms\Components\Field { $label = $params[0] ?? ''; - // Determine required flag per prompt type (indexes differ) $required = match ($method) { 'text', 'textarea', 'password' => ($params[3] ?? false) !== false, 'multiselect' => ($params[3] ?? false) !== false, 'confirm' => ($params[2] ?? false) !== false, - 'select' => ($params[2] ?? null) === null, // Required wenn kein Default gesetzt + 'select' => ($params[2] ?? null) === null, default => false, }; $defaultValue = $this->answers[$promptId] ?? null; @@ -559,7 +556,6 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $defaultSelect = $defaultValue ?? ($params[2] ?? null); $confirmDefault = $defaultValue ?? ($params[1] ?? false); - // Map validate parameter per method $validate = match ($method) { 'text', 'textarea', 'password' => $params[4] ?? null, 'select' => $params[5] ?? null, @@ -567,7 +563,6 @@ protected function createFieldFromPrompt(string $promptId, string $method, array default => null, }; - // Build validation rules for Filament fields (normalize pipe-delimited) $rules = []; $pushRules = function (array &$into, string|array|null $value): void { if ($value === null || $value === false || $value === '') { @@ -586,7 +581,6 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $rules[] = 'required'; } - // Add method-specific rules if ($method === 'select' && $required && ! empty($options)) { $rules[] = 'in:'.implode(',', array_keys($options)); } @@ -597,8 +591,6 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $pushRules($rules, $validate); - // Kein automatisches Default für Select - Validation wird verwendet - return match ($method) { 'text' => TextInput::make($promptId) ->label($label) @@ -656,3 +648,4 @@ public function render() return view('moox-prompts::filament.components.run-command'); } } + \ No newline at end of file diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index 33b45e292..fdc4f2801 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -4,6 +4,8 @@ use Moox\Core\MooxServiceProvider; use Moox\Prompts\Support\CliPromptRuntime; +use Moox\Prompts\Support\PromptFlowRunner; +use Moox\Prompts\Support\PromptFlowStateStore; use Moox\Prompts\Support\PromptResponseStore; use Moox\Prompts\Support\PromptRuntime; use Moox\Prompts\Support\WebPromptRuntime; @@ -27,6 +29,28 @@ public function register() return new PromptResponseStore; }); + $this->app->singleton(PromptFlowStateStore::class, function ($app) { + $store = cache()->store(); + + // Avoid volatile in-memory array cache which loses flow state between requests. + if ($store->getStore() instanceof \Illuminate\Cache\ArrayStore) { + $store = cache()->store('file'); + } + + return new PromptFlowStateStore( + $store, + 'moox_prompts_flow:', + 3600 + ); + }); + + $this->app->singleton(PromptFlowRunner::class, function ($app) { + return new PromptFlowRunner( + $app->make(\Illuminate\Contracts\Console\Kernel::class), + $app->make(PromptFlowStateStore::class) + ); + }); + $this->app->singleton(PromptRuntime::class, function ($app) { if (php_sapi_name() === 'cli') { return new CliPromptRuntime; @@ -51,4 +75,5 @@ public function boot(): void ); } } + } diff --git a/packages/prompts/src/Support/CliPromptRuntime.php b/packages/prompts/src/Support/CliPromptRuntime.php index b2756ea2d..409b30742 100644 --- a/packages/prompts/src/Support/CliPromptRuntime.php +++ b/packages/prompts/src/Support/CliPromptRuntime.php @@ -9,12 +9,6 @@ class CliPromptRuntime implements PromptRuntime { - /* - |-------------------------------------------------------------------------- - | Input Prompts - |-------------------------------------------------------------------------- - */ - public function text( string $label, string $placeholder = '', @@ -202,23 +196,11 @@ public function multisearch( ); } - /* - |-------------------------------------------------------------------------- - | Auxiliary Prompts - |-------------------------------------------------------------------------- - */ - public function pause(string $message = 'Press ENTER to continue'): void { Prompts\pause($message); } - /* - |-------------------------------------------------------------------------- - | Informational - |-------------------------------------------------------------------------- - */ - public function note(string $message): void { Prompts\note($message); @@ -254,23 +236,11 @@ public function outro(string $message): void Prompts\outro($message); } - /* - |-------------------------------------------------------------------------- - | Table Output - |-------------------------------------------------------------------------- - */ - public function table(array $headers, array $rows): void { Prompts\table($headers, $rows); } - /* - |-------------------------------------------------------------------------- - | Spinner - |-------------------------------------------------------------------------- - */ - public function spin( Closure $callback, string $message = '', @@ -278,12 +248,6 @@ public function spin( return Prompts\spin($callback, $message); } - /* - |-------------------------------------------------------------------------- - | Progress - |-------------------------------------------------------------------------- - */ - public function progress( string $label, iterable|int $steps, @@ -298,23 +262,11 @@ public function progress( ); } - /* - |-------------------------------------------------------------------------- - | Clear Terminal - |-------------------------------------------------------------------------- - */ - public function clear(): void { Prompts\clear(); } - /* - |-------------------------------------------------------------------------- - | Form - |-------------------------------------------------------------------------- - */ - public function form(): FormBuilder { return Prompts\form(); diff --git a/packages/prompts/src/Support/FlowCommand.php b/packages/prompts/src/Support/FlowCommand.php new file mode 100644 index 000000000..6f7b4d4f7 --- /dev/null +++ b/packages/prompts/src/Support/FlowCommand.php @@ -0,0 +1,39 @@ +promptFlowSteps() as $step) { + if (method_exists($this, $step)) { + $this->{$step}(); + } + } + + return self::SUCCESS; + } +} + + diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php new file mode 100644 index 000000000..29b1af2e4 --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -0,0 +1,209 @@ +resolveCommand($commandName); + $steps = ($command instanceof PromptFlowCommand) + ? $command->promptFlowSteps() + : ['handle']; + + if (empty($steps)) { + $steps = ['handle']; + } + + return $this->stateStore->create($commandName, array_values($steps)); + } + + public function get(string $flowId): ?PromptFlowState + { + return $this->stateStore->get($flowId); + } + + public function runNext( + PromptFlowState $state, + array $commandInput, + PromptResponseStore $responseStore, + ): array { + $step = $state->nextPendingStep(); + + if ($step === null) { + return [ + 'output' => '', + 'prompt' => null, + 'completed' => true, + 'failed' => false, + 'error' => null, + 'state' => $state, + ]; + } + + $command = $this->resolveCommand($state->commandName); + $command->setLaravel(app()); + + $input = new ArrayInput($commandInput); + $output = new BufferedOutput; + $outputStyle = new OutputStyle($input, $output); + $command->setOutput($outputStyle); + $this->setCommandInput($command, $input); + + try { + app()->instance('moox.prompts.response_store', $responseStore); + // aktuell ausgeführten Step für die Web-Runtime verfügbar machen + app()->instance('moox.prompts.current_step', $step); + + // restore persisted command properties (e.g., choice) across steps + $this->restoreCommandContext($command, $state); + + $this->invokeStep($command, $step); + + // persist selected command properties back into state + $this->captureCommandContext($command, $state); + + $stepOutput = $output->fetch(); + $state->markStepFinished($step, $stepOutput); + $this->stateStore->put($state); + + return [ + 'output' => $stepOutput, + 'prompt' => null, + 'completed' => $state->completed, + 'failed' => false, + 'error' => null, + 'state' => $state, + ]; + } catch (PendingPromptsException $e) { + $stepOutput = $output->fetch(); + $this->captureCommandContext($command, $state); + $this->stateStore->put($state); + + return [ + 'output' => $stepOutput, + 'prompt' => $e->getPrompt(), + 'completed' => false, + 'failed' => false, + 'error' => null, + 'state' => $state, + ]; + } catch (Throwable $e) { + $stepOutput = $output->fetch(); + $state->markFailed($step, $e->getMessage()); + $this->captureCommandContext($command, $state); + $this->stateStore->put($state); + + return [ + 'output' => $this->appendExceptionToOutput($stepOutput, $e), + 'prompt' => null, + 'completed' => false, + 'failed' => true, + 'error' => $e->getMessage(), + 'state' => $state, + ]; + } + } + + protected function appendExceptionToOutput(string $output, Throwable $e): string + { + $trace = $e->getTraceAsString(); + + return trim($output."\n\n".$this->formatThrowableMessage($e)."\n".$trace); + } + + protected function formatThrowableMessage(Throwable $e): string + { + return sprintf( + '%s: %s in %s:%d', + $e::class, + $e->getMessage(), + $e->getFile(), + $e->getLine() + ); + } + + protected function resolveCommand(string $commandName) + { + $commandInstance = $this->artisan->all()[$commandName] ?? null; + + if (! $commandInstance) { + throw new \RuntimeException("Command nicht gefunden: {$commandName}"); + } + + return $commandInstance; + } + + protected function invokeStep($command, string $method): void + { + if (! method_exists($command, $method)) { + throw new \RuntimeException("Step {$method} nicht gefunden auf Command ".get_class($command)); + } + + $command->{$method}(); + } + + protected function setCommandInput($command, ArrayInput $input): void + { + $ref = new \ReflectionClass($command); + + if ($ref->hasProperty('input')) { + $prop = $ref->getProperty('input'); + $prop->setAccessible(true); + $prop->setValue($command, $input); + } + } + + protected function captureCommandContext($command, PromptFlowState $state): void + { + $ref = new \ReflectionObject($command); + + // Wir persistieren alle nicht-statischen Properties, die auf der konkreten Command-Klasse + // deklariert sind und skalare/Array-Werte enthalten (z.B. choice, features, projectName, ...). + foreach ($ref->getProperties() as $prop) { + if ($prop->isStatic()) { + continue; + } + + if ($prop->getDeclaringClass()->getName() !== $ref->getName()) { + // Nur Properties der konkreten Command-Klasse, nicht von der Basisklasse + continue; + } + + $prop->setAccessible(true); + $value = $prop->getValue($command); + + if (is_scalar($value) || $value === null || is_array($value)) { + $state->context[$prop->getName()] = $value; + } + } + } + + protected function restoreCommandContext($command, PromptFlowState $state): void + { + if (empty($state->context)) { + return; + } + + $ref = new \ReflectionObject($command); + foreach ($state->context as $propName => $value) { + if ($ref->hasProperty($propName)) { + $prop = $ref->getProperty($propName); + $prop->setAccessible(true); + $prop->setValue($command, $value); + } + } + } +} + diff --git a/packages/prompts/src/Support/PromptFlowState.php b/packages/prompts/src/Support/PromptFlowState.php new file mode 100644 index 000000000..07f34d43f --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowState.php @@ -0,0 +1,43 @@ +completed) { + return null; + } + + return $this->steps[$this->currentIndex] ?? null; + } + + public function markStepFinished(string $step, string $output = ''): void + { + $this->stepOutputs[$step] = $output; + $this->currentIndex++; + if ($this->currentIndex >= count($this->steps)) { + $this->completed = true; + } + } + + public function markFailed(string $step, string $message): void + { + $this->failedAt = $step; + $this->errorMessage = $message; + } +} + diff --git a/packages/prompts/src/Support/PromptFlowStateStore.php b/packages/prompts/src/Support/PromptFlowStateStore.php new file mode 100644 index 000000000..b642f5890 --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowStateStore.php @@ -0,0 +1,45 @@ +put($state); + + return $state; + } + + public function get(string $flowId): ?PromptFlowState + { + return $this->cache->get($this->key($flowId)); + } + + public function put(PromptFlowState $state): void + { + $this->cache->put($this->key($state->flowId), $state, $this->ttlSeconds); + } + + public function reset(string $flowId): void + { + $this->cache->forget($this->key($flowId)); + } + + protected function key(string $flowId): string + { + return $this->prefix.$flowId; + } +} + diff --git a/packages/prompts/src/Support/PromptRuntime.php b/packages/prompts/src/Support/PromptRuntime.php index f2582899c..0f024d5c0 100644 --- a/packages/prompts/src/Support/PromptRuntime.php +++ b/packages/prompts/src/Support/PromptRuntime.php @@ -101,12 +101,6 @@ public function multisearch( public function pause(string $message = 'Press ENTER to continue'): void; - /* - |-------------------------------------------------------------------------- - | Informational Messages - |-------------------------------------------------------------------------- - */ - public function note(string $message): void; public function info(string $message): void; @@ -121,31 +115,13 @@ public function intro(string $message): void; public function outro(string $message): void; - /* - |-------------------------------------------------------------------------- - | Table Output - |-------------------------------------------------------------------------- - */ - public function table(array $headers, array $rows): void; - /* - |-------------------------------------------------------------------------- - | Spinner - |-------------------------------------------------------------------------- - */ - public function spin( Closure $callback, string $message = '', ): mixed; - /* - |-------------------------------------------------------------------------- - | Progress - |-------------------------------------------------------------------------- - */ - public function progress( string $label, iterable|int $steps, @@ -153,19 +129,7 @@ public function progress( string $hint = '', ): Progress|array; - /* - |-------------------------------------------------------------------------- - | Clear Terminal - |-------------------------------------------------------------------------- - */ - public function clear(): void; - /* - |-------------------------------------------------------------------------- - | Form - |-------------------------------------------------------------------------- - */ - public function form(): FormBuilder; } diff --git a/packages/prompts/src/Support/WebPromptRuntime.php b/packages/prompts/src/Support/WebPromptRuntime.php index b4068f641..460f70e82 100644 --- a/packages/prompts/src/Support/WebPromptRuntime.php +++ b/packages/prompts/src/Support/WebPromptRuntime.php @@ -17,6 +17,14 @@ public function __construct() protected function generatePromptId(string $method): string { + // Wenn der aktuelle Step vom FlowRunner gesetzt wurde, verwenden wir ihn + // als stabile Prompt-ID, damit jeder Step genau einen Prompt hat und + // Antworten nicht zwischen Steps vermischt werden. + if (app()->bound('moox.prompts.current_step')) { + return app('moox.prompts.current_step'); + } + + // Fallback für generische Nutzung (z.B. CLI oder ohne Flow-Kontext) return $this->responseStore->getNextPromptId($method); } @@ -42,6 +50,13 @@ protected function checkOrThrow(string $promptId, array $promptData): mixed return $value; } + // Für alle anderen Prompts erwarten wir skalare Werte. + // Falls dennoch ein Array im Store liegt (z.B. durch Formular-State), + // wandeln wir es in einen String um, um Typfehler zu vermeiden. + if (is_array($value)) { + return implode(', ', array_map('strval', $value)); + } + return $value; } diff --git a/packages/prompts/src/functions.php b/packages/prompts/src/functions.php index e36e7ea78..43170896c 100644 --- a/packages/prompts/src/functions.php +++ b/packages/prompts/src/functions.php @@ -7,12 +7,6 @@ use Laravel\Prompts\Progress; use Moox\Prompts\Support\PromptRuntime; -/* -|-------------------------------------------------------------------------- -| Input Prompts -|-------------------------------------------------------------------------- -*/ - function text( string $label, string $placeholder = '', @@ -146,23 +140,11 @@ function multisearch( ); } -/* -|-------------------------------------------------------------------------- -| Auxiliary Prompts -|-------------------------------------------------------------------------- -*/ - function pause(string $message = 'Press ENTER to continue'): void { app(PromptRuntime::class)->pause($message); } -/* -|-------------------------------------------------------------------------- -| Informational -|-------------------------------------------------------------------------- -*/ - function note(string $message): void { app(PromptRuntime::class)->note($message); @@ -198,34 +180,16 @@ function outro(string $message): void app(PromptRuntime::class)->outro($message); } -/* -|-------------------------------------------------------------------------- -| Table -|-------------------------------------------------------------------------- -*/ - function table(array $headers, array $rows): void { app(PromptRuntime::class)->table($headers, $rows); } -/* -|-------------------------------------------------------------------------- -| Spinner -|-------------------------------------------------------------------------- -*/ - function spin(Closure $callback, string $message = ''): mixed { return app(PromptRuntime::class)->spin($callback, $message); } -/* -|-------------------------------------------------------------------------- -| Progress -|-------------------------------------------------------------------------- -*/ - function progress( string $label, iterable|int $steps, @@ -237,23 +201,11 @@ function progress( ); } -/* -|-------------------------------------------------------------------------- -| Clear -|-------------------------------------------------------------------------- -*/ - function clear(): void { app(PromptRuntime::class)->clear(); } -/* -|-------------------------------------------------------------------------- -| Form -|-------------------------------------------------------------------------- -*/ - function form(): FormBuilder { return app(PromptRuntime::class)->form(); From 4b8fa6252c50ddf2a9b68c4de402379a4eb4ab1f Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:02:22 +0000 Subject: [PATCH 06/16] Fix styling --- .../prompts/src/Filament/Components/RunCommandComponent.php | 3 +-- packages/prompts/src/PromptsServiceProvider.php | 1 - packages/prompts/src/Support/FlowCommand.php | 2 -- packages/prompts/src/Support/PromptFlowRunner.php | 3 +-- packages/prompts/src/Support/PromptFlowState.php | 1 - packages/prompts/src/Support/PromptFlowStateStore.php | 1 - 6 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index e69808f39..d090bcec2 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -16,8 +16,8 @@ use Moox\Prompts\Support\PromptFlowStateStore; use Moox\Prompts\Support\PromptResponseStore; use Moox\Prompts\Support\PromptRuntime; -use Moox\Prompts\Support\WebPromptRuntime; use Moox\Prompts\Support\WebCommandRunner; +use Moox\Prompts\Support\WebPromptRuntime; use Throwable; class RunCommandComponent extends Component implements HasForms @@ -648,4 +648,3 @@ public function render() return view('moox-prompts::filament.components.run-command'); } } - \ No newline at end of file diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index fdc4f2801..ec09ff746 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -75,5 +75,4 @@ public function boot(): void ); } } - } diff --git a/packages/prompts/src/Support/FlowCommand.php b/packages/prompts/src/Support/FlowCommand.php index 6f7b4d4f7..8ce10fc34 100644 --- a/packages/prompts/src/Support/FlowCommand.php +++ b/packages/prompts/src/Support/FlowCommand.php @@ -35,5 +35,3 @@ public function handle(): int return self::SUCCESS; } } - - diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index 29b1af2e4..514e50380 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -2,8 +2,8 @@ namespace Moox\Prompts\Support; -use Illuminate\Contracts\Console\Kernel; use Illuminate\Console\OutputStyle; +use Illuminate\Contracts\Console\Kernel; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Throwable; @@ -206,4 +206,3 @@ protected function restoreCommandContext($command, PromptFlowState $state): void } } } - diff --git a/packages/prompts/src/Support/PromptFlowState.php b/packages/prompts/src/Support/PromptFlowState.php index 07f34d43f..873e1246e 100644 --- a/packages/prompts/src/Support/PromptFlowState.php +++ b/packages/prompts/src/Support/PromptFlowState.php @@ -40,4 +40,3 @@ public function markFailed(string $step, string $message): void $this->errorMessage = $message; } } - diff --git a/packages/prompts/src/Support/PromptFlowStateStore.php b/packages/prompts/src/Support/PromptFlowStateStore.php index b642f5890..54a7b89e4 100644 --- a/packages/prompts/src/Support/PromptFlowStateStore.php +++ b/packages/prompts/src/Support/PromptFlowStateStore.php @@ -42,4 +42,3 @@ protected function key(string $flowId): string return $this->prefix.$flowId; } } - From 705c064e3dfd56d6d2968460e418ce817ce6759b Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:34:34 +0100 Subject: [PATCH 07/16] refactor: use PromptParamsHelper for robust parameter access Replace direct array index access ($params[0], $params[1]) with named parameter access via PromptParamsHelper. This centralizes parameter mapping and makes the code more maintainable and robust against Laravel Prompts updates. --- .../Components/RunCommandComponent.php | 179 ++++++++++++------ .../src/Support/PromptParamsHelper.php | 98 ++++++++++ 2 files changed, 217 insertions(+), 60 deletions(-) create mode 100644 packages/prompts/src/Support/PromptParamsHelper.php diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index d090bcec2..9426adaea 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -14,6 +14,7 @@ use Livewire\Component; use Moox\Prompts\Support\PromptFlowRunner; use Moox\Prompts\Support\PromptFlowStateStore; +use Moox\Prompts\Support\PromptParamsHelper; use Moox\Prompts\Support\PromptResponseStore; use Moox\Prompts\Support\PromptRuntime; use Moox\Prompts\Support\WebCommandRunner; @@ -177,24 +178,64 @@ protected function appendOutput(?string $newOutput): void protected function prefillPromptForm(array $prompt): void { $promptId = $prompt['id'] ?? null; - if (! $promptId || ! isset($this->answers[$promptId])) { + if (! $promptId) { return; } - $value = $this->answers[$promptId]; - if (($prompt['method'] ?? '') === 'multiselect') { - if (! is_array($value)) { - if ($value === true) { - $params = $prompt['params'] ?? []; - $options = $params[1] ?? []; - $value = array_keys($options); - } else { - $value = []; + $method = $prompt['method'] ?? ''; + $params = $prompt['params'] ?? []; + $p = PromptParamsHelper::extract($method, $params); + + // Wenn bereits eine Antwort vorhanden ist, diese verwenden + if (isset($this->answers[$promptId])) { + $value = $this->answers[$promptId]; + if ($method === 'multiselect') { + if (! is_array($value)) { + if ($value === true) { + $options = $p['options'] ?? []; + $value = array_keys($options); + } else { + $value = []; + } } } + $this->form->fill([$promptId => $value]); + return; + } + + // Ansonsten Default-Wert aus den Prompt-Params verwenden + if ($method === 'confirm') { + $default = $p['default'] ?? false; // default Parameter (bool) + $value = $default ? 'yes' : 'no'; + $this->form->fill([$promptId => $value]); + return; + } + + if ($method === 'multiselect') { + $defaultValue = $p['default'] ?? []; // default Parameter (array) + // Für multiselect müssen wir die einzelnen Checkboxen füllen + $options = $p['options'] ?? []; + $fillData = []; + foreach (array_keys($options) as $key) { + $checkboxId = $promptId.'_'.$key; + $fillData[$checkboxId] = is_array($defaultValue) && in_array($key, $defaultValue); + } + if (! empty($fillData)) { + $this->form->fill($fillData); + } + return; } - $this->form->fill([$promptId => $value]); + $defaultValue = null; + if ($method === 'select') { + $defaultValue = $p['default'] ?? null; // default Parameter + } elseif (in_array($method, ['text', 'textarea', 'password'])) { + $defaultValue = $p['default'] ?? ''; // default Parameter + } + + if ($defaultValue !== null) { + $this->form->fill([$promptId => $defaultValue]); + } } protected function formatThrowableMessage(Throwable $e): string @@ -276,7 +317,7 @@ public function submitPrompt(): void $answer = false; } - if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && ! is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { + if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && ! is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { try { $data = $this->form->getState(); $answer = $data[$promptId] ?? null; @@ -353,7 +394,11 @@ public function submitPrompt(): void } if ($this->currentPrompt['method'] === 'confirm') { - if (! is_bool($answer)) { + if ($answer === 'yes') { + $answer = true; + } elseif ($answer === 'no') { + $answer = false; + } elseif (! is_bool($answer)) { $answer = (bool) $answer; } } @@ -377,24 +422,26 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ $method = $prompt['method'] ?? ''; $params = $prompt['params'] ?? []; + $p = PromptParamsHelper::extract($method, $params); + $rules = []; $messages = []; $requiredFlag = match ($method) { - 'text', 'textarea', 'password' => $params[3] ?? false, - 'multiselect' => $params[3] ?? false, - 'confirm' => $params[2] ?? false, + 'text', 'textarea', 'password' => $p['required'] ?? false, + 'multiselect' => $p['required'] ?? false, + 'confirm' => $p['required'] ?? false, default => false, }; - if ($method === 'multiselect' && ! empty($params[1] ?? [])) { + if ($method === 'multiselect' && ! empty($p['options'] ?? [])) { $requiredFlag = true; } $validate = match ($method) { - 'text', 'textarea', 'password' => $params[4] ?? null, - 'select' => $params[5] ?? null, - 'multiselect' => $params[6] ?? null, + 'text', 'textarea', 'password' => $p['validate'] ?? null, + 'select' => $p['validate'] ?? null, + 'multiselect' => $p['validate'] ?? null, default => null, }; @@ -420,12 +467,12 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ } } - if ($method === 'confirm') { - $rules[] = 'boolean'; + if ($method === 'confirm' && $requiredFlag !== false) { + $rules[] = 'required'; } if ($method === 'select' && $requiredFlag !== false) { - $rules[] = 'in:'.implode(',', array_keys($params[1] ?? [])); + $rules[] = 'in:'.implode(',', array_keys($p['options'] ?? [])); } $pushRules($rules, $validate); @@ -449,19 +496,25 @@ protected function validatePromptAnswer(string $promptId, mixed $answer, array $ $callableErrors[] = $result; } if ($result === false) { - $callableErrors[] = 'Ungültiger Wert.'; + $callableErrors[] = __('moox-prompts::prompts.validation.callable_invalid'); } } if (! empty($rules)) { + $label = $p['label'] ?? $promptId; + + if (in_array($method, ['text', 'textarea', 'password'])) { + $messages["{$promptId}.required"] = __('moox-prompts::prompts.validation.text_required', ['label' => $label]); + } + if ($method === 'multiselect') { - $messages["{$promptId}.required"] = 'Bitte mindestens eine Option wählen.'; - $messages["{$promptId}.min"] = 'Bitte mindestens eine Option wählen.'; + $messages["{$promptId}.required"] = __('moox-prompts::prompts.validation.multiselect_required'); + $messages["{$promptId}.min"] = __('moox-prompts::prompts.validation.multiselect_min'); } if ($method === 'select') { - $messages["{$promptId}.required"] = 'Bitte wählen Sie eine Option aus.'; - $messages["{$promptId}.in"] = 'Bitte wählen Sie eine gültige Option aus.'; + $messages["{$promptId}.required"] = __('moox-prompts::prompts.validation.select_required'); + $messages["{$promptId}.in"] = __('moox-prompts::prompts.validation.select_in'); } $validator = Validator::make( @@ -517,10 +570,13 @@ protected function getFormSchema(): array protected function createMultiselectFields(string $promptId, array $params): array { - $label = $params[0] ?? ''; - $required = ($params[3] ?? false) !== false; - $defaultValue = $this->answers[$promptId] ?? null; - $options = $params[1] ?? []; + $p = PromptParamsHelper::extract('multiselect', $params); + + $label = $p['label'] ?? ''; + $required = ($p['required'] ?? false) !== false; + // Default-Wert: erst aus answers, dann aus default-Parameter + $defaultValue = $this->answers[$promptId] ?? ($p['default'] ?? []); + $options = $p['options'] ?? []; $fields = []; @@ -543,23 +599,30 @@ protected function createMultiselectFields(string $promptId, array $params): arr protected function createFieldFromPrompt(string $promptId, string $method, array $params): ?\Filament\Forms\Components\Field { - $label = $params[0] ?? ''; + $p = PromptParamsHelper::extract($method, $params); + + $label = $p['label'] ?? ''; $required = match ($method) { - 'text', 'textarea', 'password' => ($params[3] ?? false) !== false, - 'multiselect' => ($params[3] ?? false) !== false, - 'confirm' => ($params[2] ?? false) !== false, - 'select' => ($params[2] ?? null) === null, + 'text', 'textarea', 'password' => ($p['required'] ?? false) !== false, + 'multiselect' => ($p['required'] ?? false) !== false, + 'confirm' => ($p['required'] ?? false) !== false, + 'select' => ($p['default'] ?? null) === null, default => false, }; $defaultValue = $this->answers[$promptId] ?? null; - $options = $params[1] ?? []; - $defaultSelect = $defaultValue ?? ($params[2] ?? null); - $confirmDefault = $defaultValue ?? ($params[1] ?? false); + $options = $p['options'] ?? []; + $defaultSelect = $defaultValue ?? ($p['default'] ?? null); + + // Für confirm: Default aus params[1] (default Parameter), falls noch keine Antwort vorhanden + $confirmDefault = null; + if ($method === 'confirm') { + $confirmDefault = $defaultValue !== null ? $defaultValue : ($p['default'] ?? false); + } $validate = match ($method) { - 'text', 'textarea', 'password' => $params[4] ?? null, - 'select' => $params[5] ?? null, - 'multiselect' => $params[6] ?? null, + 'text', 'textarea', 'password' => $p['validate'] ?? null, + 'select' => $p['validate'] ?? null, + 'multiselect' => $p['validate'] ?? null, default => null, }; @@ -585,36 +648,32 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $rules[] = 'in:'.implode(',', array_keys($options)); } - if ($method === 'confirm') { - $rules[] = 'boolean'; - } - $pushRules($rules, $validate); return match ($method) { 'text' => TextInput::make($promptId) ->label($label) - ->placeholder($params[1] ?? '') - ->default($defaultValue ?? $params[2] ?? '') + ->placeholder($p['placeholder'] ?? '') + ->default($defaultValue ?? $p['default'] ?? '') ->rules($rules) - ->hint($params[6] ?? null) + ->hint($p['hint'] ?? null) ->live(onBlur: false), 'textarea' => Textarea::make($promptId) ->label($label) - ->placeholder($params[1] ?? '') - ->default($defaultValue ?? $params[2] ?? '') + ->placeholder($p['placeholder'] ?? '') + ->default($defaultValue ?? $p['default'] ?? '') ->rules($rules) ->rows(5) - ->hint($params[6] ?? null), + ->hint($p['hint'] ?? null), 'password' => TextInput::make($promptId) ->label($label) ->password() - ->placeholder($params[1] ?? '') + ->placeholder($p['placeholder'] ?? '') ->default($defaultValue ?? '') ->rules($rules) - ->hint($params[6] ?? null) + ->hint($p['hint'] ?? null) ->live(onBlur: false), 'select' => Select::make($promptId) @@ -623,7 +682,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array ->default($defaultSelect !== null ? $defaultSelect : null) ->rules($rules) ->placeholder('Bitte wählen...') - ->hint($params[4] ?? null) + ->hint($p['hint'] ?? null) ->live(onBlur: false), 'multiselect' => null, @@ -631,12 +690,12 @@ protected function createFieldFromPrompt(string $promptId, string $method, array 'confirm' => Radio::make($promptId) ->label($label) ->options([ - true => 'Ja', - false => 'Nein', + 'yes' => 'Ja', + 'no' => 'Nein', ]) - ->default($confirmDefault) + ->default($confirmDefault !== null ? ($confirmDefault ? 'yes' : 'no') : null) ->rules($rules) - ->hint($params[6] ?? null) + ->hint($p['hint'] ?? null) ->live(onBlur: false), default => null, diff --git a/packages/prompts/src/Support/PromptParamsHelper.php b/packages/prompts/src/Support/PromptParamsHelper.php new file mode 100644 index 000000000..58456bc89 --- /dev/null +++ b/packages/prompts/src/Support/PromptParamsHelper.php @@ -0,0 +1,98 @@ + [ + 'label' => $params[0] ?? '', + 'placeholder' => $params[1] ?? '', + 'default' => $params[2] ?? null, + 'required' => $params[3] ?? false, + 'validate' => $params[4] ?? null, + 'hint' => $params[5] ?? '', + 'transform' => $params[6] ?? null, + ], + 'textarea' => [ + 'label' => $params[0] ?? '', + 'placeholder' => $params[1] ?? '', + 'required' => $params[2] ?? false, + 'validate' => $params[3] ?? null, + 'hint' => $params[4] ?? '', + 'transform' => $params[5] ?? null, + ], + 'password' => [ + 'label' => $params[0] ?? '', + 'placeholder' => $params[1] ?? '', + 'required' => $params[2] ?? false, + 'validate' => $params[3] ?? null, + 'hint' => $params[4] ?? '', + 'transform' => $params[5] ?? null, + ], + 'confirm' => [ + 'label' => $params[0] ?? '', + 'default' => $params[1] ?? false, + 'required' => $params[2] ?? false, + 'yes' => $params[3] ?? 'I accept', + 'no' => $params[4] ?? 'I decline', + 'hint' => $params[5] ?? '', + ], + 'select' => [ + 'label' => $params[0] ?? '', + 'options' => $params[1] ?? [], + 'default' => $params[2] ?? null, + 'scroll' => $params[3] ?? null, + 'hint' => $params[4] ?? '', + 'validate' => $params[5] ?? null, + 'transform' => $params[6] ?? null, + ], + 'multiselect' => [ + 'label' => $params[0] ?? '', + 'options' => $params[1] ?? [], + 'default' => $params[2] ?? [], + 'required' => $params[3] ?? false, + 'scroll' => $params[4] ?? null, + 'hint' => $params[5] ?? '', + 'validate' => $params[6] ?? null, + 'transform' => $params[7] ?? null, + ], + default => [], + }; + } + + /** + * Gibt einen einzelnen Parameter zurück. + * + * @param string $method Die Prompt-Methode + * @param array $params Die numerischen Parameter-Array + * @param string $paramName Der Name des Parameters (z.B. 'label', 'default') + * @param mixed $default Der Default-Wert, falls der Parameter nicht existiert + * @return mixed + */ + public static function get(string $method, array $params, string $paramName, mixed $default = null): mixed + { + $extracted = self::extract($method, $params); + + return $extracted[$paramName] ?? $default; + } +} + From 35b48987c8f54191ab5b1768920641f618e329fc Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:35:19 +0000 Subject: [PATCH 08/16] Fix styling --- .../Components/RunCommandComponent.php | 9 ++++--- .../src/Support/PromptParamsHelper.php | 24 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 9426adaea..24b047e11 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -185,7 +185,7 @@ protected function prefillPromptForm(array $prompt): void $method = $prompt['method'] ?? ''; $params = $prompt['params'] ?? []; $p = PromptParamsHelper::extract($method, $params); - + // Wenn bereits eine Antwort vorhanden ist, diese verwenden if (isset($this->answers[$promptId])) { $value = $this->answers[$promptId]; @@ -200,6 +200,7 @@ protected function prefillPromptForm(array $prompt): void } } $this->form->fill([$promptId => $value]); + return; } @@ -208,6 +209,7 @@ protected function prefillPromptForm(array $prompt): void $default = $p['default'] ?? false; // default Parameter (bool) $value = $default ? 'yes' : 'no'; $this->form->fill([$promptId => $value]); + return; } @@ -223,6 +225,7 @@ protected function prefillPromptForm(array $prompt): void if (! empty($fillData)) { $this->form->fill($fillData); } + return; } @@ -317,7 +320,7 @@ public function submitPrompt(): void $answer = false; } - if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && ! is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { + if (($answer === null || $answer === '' || ($this->currentPrompt['method'] === 'multiselect' && ! is_array($answer))) && $this->currentPrompt['method'] !== 'confirm') { try { $data = $this->form->getState(); $answer = $data[$promptId] ?? null; @@ -612,7 +615,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $defaultValue = $this->answers[$promptId] ?? null; $options = $p['options'] ?? []; $defaultSelect = $defaultValue ?? ($p['default'] ?? null); - + // Für confirm: Default aus params[1] (default Parameter), falls noch keine Antwort vorhanden $confirmDefault = null; if ($method === 'confirm') { diff --git a/packages/prompts/src/Support/PromptParamsHelper.php b/packages/prompts/src/Support/PromptParamsHelper.php index 58456bc89..2276dd2f6 100644 --- a/packages/prompts/src/Support/PromptParamsHelper.php +++ b/packages/prompts/src/Support/PromptParamsHelper.php @@ -4,10 +4,10 @@ /** * Helper-Klasse zum Zugriff auf Prompt-Parameter. - * + * * Statt direkt auf $params[0], $params[1] etc. zuzugreifen, * verwenden wir diese Helper-Methoden, die die Parameter-Namen kennen. - * + * * So sind wir robuster gegen Änderungen in Laravel Prompts, * solange die Parameter-Namen gleich bleiben. */ @@ -15,9 +15,9 @@ class PromptParamsHelper { /** * Extrahiert die Parameter für eine Prompt-Methode als assoziatives Array. - * - * @param string $method Die Prompt-Methode (z.B. 'text', 'confirm', 'select') - * @param array $params Die numerischen Parameter-Array + * + * @param string $method Die Prompt-Methode (z.B. 'text', 'confirm', 'select') + * @param array $params Die numerischen Parameter-Array * @return array Assoziatives Array mit Parameternamen als Keys */ public static function extract(string $method, array $params): array @@ -81,18 +81,16 @@ public static function extract(string $method, array $params): array /** * Gibt einen einzelnen Parameter zurück. - * - * @param string $method Die Prompt-Methode - * @param array $params Die numerischen Parameter-Array - * @param string $paramName Der Name des Parameters (z.B. 'label', 'default') - * @param mixed $default Der Default-Wert, falls der Parameter nicht existiert - * @return mixed + * + * @param string $method Die Prompt-Methode + * @param array $params Die numerischen Parameter-Array + * @param string $paramName Der Name des Parameters (z.B. 'label', 'default') + * @param mixed $default Der Default-Wert, falls der Parameter nicht existiert */ public static function get(string $method, array $params, string $paramName, mixed $default = null): mixed { $extracted = self::extract($method, $params); - + return $extracted[$paramName] ?? $default; } } - From 1ad990dafc0b624af6fa93cdd12d60d98f8c43e5 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:01:38 +0100 Subject: [PATCH 09/16] remove empty placeholder option from select prompts --- .../prompts/src/Filament/Components/RunCommandComponent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 24b047e11..b9f0f529a 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -684,7 +684,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array ->options($options) ->default($defaultSelect !== null ? $defaultSelect : null) ->rules($rules) - ->placeholder('Bitte wählen...') + ->selectablePlaceholder(false) ->hint($p['hint'] ?? null) ->live(onBlur: false), From 9eafce642995e7f796c76b59943cb23d5646f0c0 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:26:51 +0100 Subject: [PATCH 10/16] translations --- .../prompts/resources/lang/de/prompts.php | 34 +++++++++++++++++++ .../prompts/resources/lang/en/prompts.php | 34 +++++++++++++++++++ .../filament/components/run-command.blade.php | 14 ++++---- .../filament/pages/run-command.blade.php | 12 +++---- .../Components/RunCommandComponent.php | 30 ++++++++-------- .../src/Filament/Pages/RunCommandPage.php | 21 ++++++++++-- .../prompts/src/PromptsServiceProvider.php | 3 +- packages/prompts/src/Support/FlowCommand.php | 14 ++++---- .../prompts/src/Support/PromptFlowRunner.php | 15 ++++---- .../src/Support/PromptParamsHelper.php | 28 +++++++-------- .../prompts/src/Support/WebPromptRuntime.php | 22 ++++++++---- 11 files changed, 162 insertions(+), 65 deletions(-) diff --git a/packages/prompts/resources/lang/de/prompts.php b/packages/prompts/resources/lang/de/prompts.php index a5e40ea3a..57b5bc76f 100644 --- a/packages/prompts/resources/lang/de/prompts.php +++ b/packages/prompts/resources/lang/de/prompts.php @@ -3,4 +3,38 @@ return [ 'prompt' => 'Prompt', 'prompts' => 'Prompts', + + 'ui' => [ + 'error_heading' => 'Fehler', + 'success_heading' => 'Command erfolgreich abgeschlossen!', + 'starting_heading' => 'Command wird gestartet...', + 'validation_title' => 'Bitte korrigieren:', + 'next_button' => 'Weiter', + 'output_heading' => 'Command Ausgabe', + 'confirm_yes' => 'Ja', + 'confirm_no' => 'Nein', + 'no_commands_available' => 'Keine Commands verfügbar. Bitte konfiguriere die erlaubten Commands in der', + 'command_label' => 'Command', + 'select_command_placeholder' => 'Bitte Command auswählen …', + 'commands_config_hint' => 'Nur Commands aus der Konfiguration sind hier sichtbar.', + 'start_command_button' => 'Command starten', + 'back_to_selection' => 'Zurück zur Command-Auswahl', + 'unknown_error' => 'Unbekannter Fehler', + 'navigation_label' => 'Command Runner', + 'navigation_group' => 'System', + ], + + 'errors' => [ + 'command_not_found' => 'Command nicht gefunden: :command', + 'step_not_found' => 'Step :step nicht gefunden auf Command :class', + ], + + 'validation' => [ + 'text_required' => 'Bitte „:label“ ausfüllen.', + 'multiselect_required' => 'Bitte mindestens eine Option wählen.', + 'multiselect_min' => 'Bitte mindestens eine Option wählen.', + 'select_required' => 'Bitte wählen Sie eine Option aus.', + 'select_in' => 'Bitte wählen Sie eine gültige Option aus.', + 'callable_invalid' => 'Ungültiger Wert.', + ], ]; diff --git a/packages/prompts/resources/lang/en/prompts.php b/packages/prompts/resources/lang/en/prompts.php index a5e40ea3a..f8502bb35 100644 --- a/packages/prompts/resources/lang/en/prompts.php +++ b/packages/prompts/resources/lang/en/prompts.php @@ -3,4 +3,38 @@ return [ 'prompt' => 'Prompt', 'prompts' => 'Prompts', + + 'ui' => [ + 'error_heading' => 'Error', + 'success_heading' => 'Command finished successfully!', + 'starting_heading' => 'Starting command...', + 'validation_title' => 'Please fix the following:', + 'next_button' => 'Next', + 'output_heading' => 'Command output', + 'confirm_yes' => 'Yes', + 'confirm_no' => 'No', + 'no_commands_available' => 'No commands available. Please configure allowed commands in', + 'command_label' => 'Command', + 'select_command_placeholder' => 'Please select a command …', + 'commands_config_hint' => 'Only commands from the configuration are visible here.', + 'start_command_button' => 'Start command', + 'back_to_selection' => 'Back to command selection', + 'unknown_error' => 'Unknown error', + 'navigation_label' => 'Command Runner', + 'navigation_group' => 'System', + ], + + 'errors' => [ + 'command_not_found' => 'Command not found: :command', + 'step_not_found' => 'Step :step not found on command :class', + ], + + 'validation' => [ + 'text_required' => 'Please fill in “:label”.', + 'multiselect_required' => 'Please select at least one option.', + 'multiselect_min' => 'Please select at least one option.', + 'select_required' => 'Please choose an option.', + 'select_in' => 'Please choose a valid option.', + 'callable_invalid' => 'Invalid value.', + ], ]; diff --git a/packages/prompts/resources/views/filament/components/run-command.blade.php b/packages/prompts/resources/views/filament/components/run-command.blade.php index a4378823f..5c249c8c4 100644 --- a/packages/prompts/resources/views/filament/components/run-command.blade.php +++ b/packages/prompts/resources/views/filament/components/run-command.blade.php @@ -2,7 +2,7 @@ @if($error) - Fehler + {{ __('moox-prompts::prompts.ui.error_heading') }}

{{ $error }}

@if($output) @@ -16,7 +16,7 @@ @elseif($isComplete) - Command erfolgreich abgeschlossen! + {{ __('moox-prompts::prompts.ui.success_heading') }} @if($output)
                         
                     
-                    Validierungsfehler:
+                    
+                        {{ __('moox-prompts::prompts.ui.validation_title') }}
+                    
                 
    @foreach($validationErrors as $msg) @@ -48,14 +50,14 @@
    - Weiter + {{ __('moox-prompts::prompts.ui.next_button') }}
    @if($currentStepOutput) - Command Output + {{ __('moox-prompts::prompts.ui.output_heading') }}
    {{ $currentStepOutput }}
    @@ -64,7 +66,7 @@ @else - Command wird gestartet... + {{ __('moox-prompts::prompts.ui.starting_heading') }} diff --git a/packages/prompts/resources/views/filament/pages/run-command.blade.php b/packages/prompts/resources/views/filament/pages/run-command.blade.php index c1aa74833..a2dea0d4e 100644 --- a/packages/prompts/resources/views/filament/pages/run-command.blade.php +++ b/packages/prompts/resources/views/filament/pages/run-command.blade.php @@ -4,7 +4,7 @@ @if (empty($availableCommands))

    - Keine Commands verfügbar. Bitte konfiguriere die erlaubten Commands in der + {{ __('moox-prompts::prompts.ui.no_commands_available') }} config/prompts.php . @@ -13,12 +13,12 @@

    - + @foreach ($availableCommands as $commandName => $description) @@ -52,7 +52,7 @@
    - Zurück zur Command-Auswahl + {{ __('moox-prompts::prompts.ui.back_to_selection') }}
    diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index b9f0f529a..9b2e38529 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -89,7 +89,7 @@ protected function runCommand(): void $this->isComplete = false; try { - // erzwinge Web-Runtime (nicht CLI) im Web-Kontext + // Force web runtime (not CLI) in web context app()->instance(PromptRuntime::class, new WebPromptRuntime); WebCommandRunner::ensurePublishableResourcesRegistered(); @@ -98,7 +98,7 @@ protected function runCommand(): void $state = $this->flowId ? $stateStore->get($this->flowId) : null; if (! $state) { - // frischer Flow: ResponseStore und lokale States leeren + // Fresh flow: clear ResponseStore and local states $this->responseStore->clear(); $this->responseStore->resetCounter(); $this->answers = []; @@ -112,7 +112,7 @@ protected function runCommand(): void $this->flowId = $state->flowId; } - // Antworten in den ResponseStore spiegeln (ohne den Zähler zu manipulieren) + // Mirror answers into ResponseStore (without manipulating the counter) foreach ($this->answers as $promptId => $answer) { $this->responseStore->set($promptId, $answer); } @@ -135,7 +135,7 @@ protected function runCommand(): void if (! empty($result['failed'])) { $this->currentStepOutput = $this->output; - $this->error = $result['error'] ?? 'Unbekannter Fehler'; + $this->error = $result['error'] ?? __('moox-prompts::prompts.ui.unknown_error'); $this->currentPrompt = null; return; @@ -186,7 +186,7 @@ protected function prefillPromptForm(array $prompt): void $params = $prompt['params'] ?? []; $p = PromptParamsHelper::extract($method, $params); - // Wenn bereits eine Antwort vorhanden ist, diese verwenden + // If an answer already exists, use it if (isset($this->answers[$promptId])) { $value = $this->answers[$promptId]; if ($method === 'multiselect') { @@ -204,9 +204,9 @@ protected function prefillPromptForm(array $prompt): void return; } - // Ansonsten Default-Wert aus den Prompt-Params verwenden + // Otherwise use default value from prompt params if ($method === 'confirm') { - $default = $p['default'] ?? false; // default Parameter (bool) + $default = $p['default'] ?? false; // default parameter (bool) $value = $default ? 'yes' : 'no'; $this->form->fill([$promptId => $value]); @@ -214,8 +214,8 @@ protected function prefillPromptForm(array $prompt): void } if ($method === 'multiselect') { - $defaultValue = $p['default'] ?? []; // default Parameter (array) - // Für multiselect müssen wir die einzelnen Checkboxen füllen + $defaultValue = $p['default'] ?? []; // default parameter (array) + // For multiselect we need to fill the individual checkboxes $options = $p['options'] ?? []; $fillData = []; foreach (array_keys($options) as $key) { @@ -231,9 +231,9 @@ protected function prefillPromptForm(array $prompt): void $defaultValue = null; if ($method === 'select') { - $defaultValue = $p['default'] ?? null; // default Parameter + $defaultValue = $p['default'] ?? null; // default parameter } elseif (in_array($method, ['text', 'textarea', 'password'])) { - $defaultValue = $p['default'] ?? ''; // default Parameter + $defaultValue = $p['default'] ?? ''; // default parameter } if ($defaultValue !== null) { @@ -577,7 +577,7 @@ protected function createMultiselectFields(string $promptId, array $params): arr $label = $p['label'] ?? ''; $required = ($p['required'] ?? false) !== false; - // Default-Wert: erst aus answers, dann aus default-Parameter + // Default value: first from answers, then from default parameter $defaultValue = $this->answers[$promptId] ?? ($p['default'] ?? []); $options = $p['options'] ?? []; @@ -616,7 +616,7 @@ protected function createFieldFromPrompt(string $promptId, string $method, array $options = $p['options'] ?? []; $defaultSelect = $defaultValue ?? ($p['default'] ?? null); - // Für confirm: Default aus params[1] (default Parameter), falls noch keine Antwort vorhanden + // For confirm: default from params[1] (default parameter), if no answer exists yet $confirmDefault = null; if ($method === 'confirm') { $confirmDefault = $defaultValue !== null ? $defaultValue : ($p['default'] ?? false); @@ -693,8 +693,8 @@ protected function createFieldFromPrompt(string $promptId, string $method, array 'confirm' => Radio::make($promptId) ->label($label) ->options([ - 'yes' => 'Ja', - 'no' => 'Nein', + 'yes' => __('moox-prompts::prompts.ui.confirm_yes'), + 'no' => __('moox-prompts::prompts.ui.confirm_no'), ]) ->default($confirmDefault !== null ? ($confirmDefault ? 'yes' : 'no') : null) ->rules($rules) diff --git a/packages/prompts/src/Filament/Pages/RunCommandPage.php b/packages/prompts/src/Filament/Pages/RunCommandPage.php index 7fbf2b83c..1261d0260 100644 --- a/packages/prompts/src/Filament/Pages/RunCommandPage.php +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -10,11 +10,26 @@ class RunCommandPage extends Page protected string $view = 'moox-prompts::filament.pages.run-command'; - protected static ?string $navigationLabel = 'Command Runner'; + protected static ?string $navigationLabel = null; - protected static ?string $title = 'Command Runner'; + protected static ?string $title = null; - protected static string|\UnitEnum|null $navigationGroup = 'System'; + protected static string|\UnitEnum|null $navigationGroup = null; + + public static function getNavigationLabel(): string + { + return static::$navigationLabel ?? __('moox-prompts::prompts.ui.navigation_label'); + } + + public function getTitle(): string + { + return static::$title ?? __('moox-prompts::prompts.ui.navigation_label'); + } + + public static function getNavigationGroup(): ?string + { + return static::$navigationGroup ?? __('moox-prompts::prompts.ui.navigation_group'); + } protected static ?int $navigationSort = 100; diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index ec09ff746..150373cb9 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -18,7 +18,8 @@ public function configureMoox(Package $package): void $package ->name('moox-prompts') ->hasConfigFile('prompts') - ->hasViews(); + ->hasViews() + ->hasTranslations(); } public function register() diff --git a/packages/prompts/src/Support/FlowCommand.php b/packages/prompts/src/Support/FlowCommand.php index 8ce10fc34..e98730efe 100644 --- a/packages/prompts/src/Support/FlowCommand.php +++ b/packages/prompts/src/Support/FlowCommand.php @@ -7,20 +7,20 @@ interface PromptFlowCommand { /** - * Liste der Step-Methoden, die der Flow in Reihenfolge ausführt. + * List of step methods that the flow executes in order. */ public function promptFlowSteps(): array; } /** - * Basis-Klasse für Flow-basierte Commands. + * Base class for flow-based commands. * - * - CLI: führt alle in promptFlowSteps() definierten Methoden der Reihe nach aus. - * - Web: der PromptFlowRunner ruft die gleichen Methoden stepweise auf. + * - CLI: executes all methods defined in promptFlowSteps() sequentially. + * - Web: the PromptFlowRunner calls the same methods step by step. * - * Concrete Commands müssen nur: - * - promptFlowSteps(): array implementieren - * - die entsprechenden step*-Methoden bereitstellen. + * Concrete commands only need to: + * - implement promptFlowSteps(): array + * - provide the corresponding step* methods. */ abstract class FlowCommand extends Command implements PromptFlowCommand { diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index 514e50380..0f48c2bea 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -63,7 +63,7 @@ public function runNext( try { app()->instance('moox.prompts.response_store', $responseStore); - // aktuell ausgeführten Step für die Web-Runtime verfügbar machen + // Make the currently executing step available for the web runtime app()->instance('moox.prompts.current_step', $step); // restore persisted command properties (e.g., choice) across steps @@ -139,7 +139,7 @@ protected function resolveCommand(string $commandName) $commandInstance = $this->artisan->all()[$commandName] ?? null; if (! $commandInstance) { - throw new \RuntimeException("Command nicht gefunden: {$commandName}"); + throw new \RuntimeException(__('moox-prompts::prompts.errors.command_not_found', ['command' => $commandName])); } return $commandInstance; @@ -148,7 +148,10 @@ protected function resolveCommand(string $commandName) protected function invokeStep($command, string $method): void { if (! method_exists($command, $method)) { - throw new \RuntimeException("Step {$method} nicht gefunden auf Command ".get_class($command)); + throw new \RuntimeException(__('moox-prompts::prompts.errors.step_not_found', [ + 'step' => $method, + 'class' => get_class($command), + ])); } $command->{$method}(); @@ -169,15 +172,15 @@ protected function captureCommandContext($command, PromptFlowState $state): void { $ref = new \ReflectionObject($command); - // Wir persistieren alle nicht-statischen Properties, die auf der konkreten Command-Klasse - // deklariert sind und skalare/Array-Werte enthalten (z.B. choice, features, projectName, ...). + // We persist all non-static properties declared on the concrete command class + // that contain scalar/array values (e.g. choice, features, projectName, ...). foreach ($ref->getProperties() as $prop) { if ($prop->isStatic()) { continue; } if ($prop->getDeclaringClass()->getName() !== $ref->getName()) { - // Nur Properties der konkreten Command-Klasse, nicht von der Basisklasse + // Only properties of the concrete command class, not from the base class continue; } diff --git a/packages/prompts/src/Support/PromptParamsHelper.php b/packages/prompts/src/Support/PromptParamsHelper.php index 2276dd2f6..6537f0b87 100644 --- a/packages/prompts/src/Support/PromptParamsHelper.php +++ b/packages/prompts/src/Support/PromptParamsHelper.php @@ -3,22 +3,22 @@ namespace Moox\Prompts\Support; /** - * Helper-Klasse zum Zugriff auf Prompt-Parameter. + * Helper class for accessing prompt parameters. * - * Statt direkt auf $params[0], $params[1] etc. zuzugreifen, - * verwenden wir diese Helper-Methoden, die die Parameter-Namen kennen. + * Instead of directly accessing $params[0], $params[1], etc., + * we use these helper methods that know the parameter names. * - * So sind wir robuster gegen Änderungen in Laravel Prompts, - * solange die Parameter-Namen gleich bleiben. + * This makes us more robust against changes in Laravel Prompts, + * as long as the parameter names remain the same. */ class PromptParamsHelper { /** - * Extrahiert die Parameter für eine Prompt-Methode als assoziatives Array. + * Extracts the parameters for a prompt method as an associative array. * - * @param string $method Die Prompt-Methode (z.B. 'text', 'confirm', 'select') - * @param array $params Die numerischen Parameter-Array - * @return array Assoziatives Array mit Parameternamen als Keys + * @param string $method The prompt method (e.g. 'text', 'confirm', 'select') + * @param array $params The numeric parameter array + * @return array Associative array with parameter names as keys */ public static function extract(string $method, array $params): array { @@ -80,12 +80,12 @@ public static function extract(string $method, array $params): array } /** - * Gibt einen einzelnen Parameter zurück. + * Returns a single parameter. * - * @param string $method Die Prompt-Methode - * @param array $params Die numerischen Parameter-Array - * @param string $paramName Der Name des Parameters (z.B. 'label', 'default') - * @param mixed $default Der Default-Wert, falls der Parameter nicht existiert + * @param string $method The prompt method + * @param array $params The numeric parameter array + * @param string $paramName The name of the parameter (e.g. 'label', 'default') + * @param mixed $default The default value if the parameter does not exist */ public static function get(string $method, array $params, string $paramName, mixed $default = null): mixed { diff --git a/packages/prompts/src/Support/WebPromptRuntime.php b/packages/prompts/src/Support/WebPromptRuntime.php index 460f70e82..c9937aa13 100644 --- a/packages/prompts/src/Support/WebPromptRuntime.php +++ b/packages/prompts/src/Support/WebPromptRuntime.php @@ -17,14 +17,14 @@ public function __construct() protected function generatePromptId(string $method): string { - // Wenn der aktuelle Step vom FlowRunner gesetzt wurde, verwenden wir ihn - // als stabile Prompt-ID, damit jeder Step genau einen Prompt hat und - // Antworten nicht zwischen Steps vermischt werden. + // If the current step was set by the FlowRunner, we use it + // as a stable prompt ID so that each step has exactly one prompt and + // answers are not mixed between steps. if (app()->bound('moox.prompts.current_step')) { return app('moox.prompts.current_step'); } - // Fallback für generische Nutzung (z.B. CLI oder ohne Flow-Kontext) + // Fallback for generic usage (e.g. CLI or without flow context) return $this->responseStore->getNextPromptId($method); } @@ -50,9 +50,17 @@ protected function checkOrThrow(string $promptId, array $promptData): mixed return $value; } - // Für alle anderen Prompts erwarten wir skalare Werte. - // Falls dennoch ein Array im Store liegt (z.B. durch Formular-State), - // wandeln wir es in einen String um, um Typfehler zu vermeiden. + // Für Confirm-Prompts: Wenn nichts ausgewählt wurde (null/leer), + // den Default-Wert wie in der CLI verwenden. + if ($promptData['method'] === 'confirm' && ($value === null || $value === '')) { + $default = $promptData['params'][1] ?? false; + + return (bool) $default; + } + + // For all other prompts we expect scalar values. + // If an array is still in the store (e.g. through form state), + // we convert it to a string to avoid type errors. if (is_array($value)) { return implode(', ', array_map('strval', $value)); } From c7534ed19e82fe4345f8161f5595473db2a99d04 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:56:06 +0100 Subject: [PATCH 11/16] Add command execution logging --- packages/prompts/README.md | 122 +++++++++++ packages/prompts/config/prompts.php | 17 ++ .../create_command_executions_table.php.stub | 42 ++++ .../prompts/resources/lang/de/prompts.php | 25 ++- .../prompts/resources/lang/en/prompts.php | 25 ++- .../filament/components/run-command.blade.php | 73 +++---- .../filament/pages/run-command.blade.php | 53 ++--- .../Components/RunCommandComponent.php | 116 ++++++++++- .../src/Filament/Pages/RunCommandPage.php | 37 ++++ .../prompts/src/Filament/PromptsPlugin.php | 11 +- .../Resources/CommandExecutionResource.php | 195 ++++++++++++++++++ .../Pages/ListCommandExecutions.php | 12 ++ .../Pages/ViewCommandExecution.php | 19 ++ .../prompts/src/Models/CommandExecution.php | 43 ++++ .../prompts/src/PromptsServiceProvider.php | 3 +- .../prompts/src/Support/PromptFlowRunner.php | 121 ++++++++++- .../src/Support/PromptFlowStateStore.php | 69 +++++++ 17 files changed, 913 insertions(+), 70 deletions(-) create mode 100644 packages/prompts/database/migrations/create_command_executions_table.php.stub create mode 100644 packages/prompts/src/Filament/Resources/CommandExecutionResource.php create mode 100644 packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php create mode 100644 packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php create mode 100644 packages/prompts/src/Models/CommandExecution.php diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 29f933502..b57bda5b2 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -112,6 +112,128 @@ Damit ein Command sowohl in der CLI als auch im Web korrekt als Flow funktionier Mehr ist im Command nicht nötig – keine speziellen Flow-Methoden, keine eigene Persistenz. Der Rest (CLI/Web-Unterschied, State, Web-Oberfläche) wird komplett vom Package übernommen. +## Ausführung im Browser (Filament) + +Nachdem du einen Flow-Command erstellt hast, kannst du ihn sowohl in der CLI als auch im Browser ausführen: + +### CLI-Ausführung + +```bash +php artisan prompts:project-setup +``` + +Der Command läuft wie ein normaler Laravel Artisan Command – alle Prompts werden direkt im Terminal angezeigt. + +### Web-Ausführung + +1. Öffne die Filament-Seite "Run Command" (wird automatisch im Navigation-Menü angezeigt) +2. Wähle einen Flow-Command aus der Liste +3. Klicke auf "Command starten" +4. Der Flow läuft Schritt für Schritt im Browser ab: + - Jeder Step zeigt einen Prompt (Text-Input, Select, Multiselect, Confirm, etc.) + - Nach jedem Step siehst du den Output des Steps + - Du kannst jederzeit mit "Zurück zur Command-Auswahl" abbrechen + - Nach erfolgreichem Abschluss wird der Button zu "Start new command" geändert + +**Wichtig:** Alle Commands, die im Web ausgeführt werden, werden automatisch in der Datenbank geloggt (siehe [Command Execution Logging](#command-execution-logging)). + +## Wie und warum wird Reflection verwendet? + +Wenn du nur Commands schreibst, musst du dich nicht um Reflection kümmern. +Damit du aber verstehst, was im Hintergrund passiert, hier eine kurze Erklärung. + +- **Problem 1: Argumente & Optionen im Web setzen** + Laravel speichert Argumente/Optionen intern in einem geschützten Property `$input` deines Commands. + In der CLI kümmert sich der Artisan-Kernel darum, dieses Property zu setzen. + Im Web-Flow erzeugen wir aber selbst neue Command-Instanzen – und müssen `$input` daher manuell setzen. + Genau das macht `PromptFlowRunner::setCommandInput()` mit Reflection: + - es findet das `input`-Property auf deinem Command-Objekt, + - macht es kurz zugänglich, + - und schreibt das aktuelle Input-Objekt hinein. + **Ergebnis:** In Flow-Commands kannst du überall ganz normal `argument()` und `option()` verwenden – egal ob der Command per CLI oder im Browser läuft. + +- **Problem 2: Command-State zwischen Web-Requests merken** + Im Web besteht dein Flow aus mehreren HTTP-Requests. Ohne zusätzliche Logik wären Properties wie `$environment`, `$features`, `$projectName` im nächsten Step einfach weg. + `PromptFlowRunner` löst das mit zwei internen Methoden: + - `captureCommandContext($command, $state)` + - liest per Reflection alle nicht-statischen Properties deiner konkreten Command-Klasse aus + - speichert einfache Werte (Scalars, Arrays, `null`) im `PromptFlowState::$context` + - `restoreCommandContext($command, $state)` + - setzt beim nächsten Request alle gespeicherten Werte wieder zurück auf das neue Command-Objekt + **Ergebnis:** Für deinen Code fühlt es sich so an, als würde derselbe Command einfach weiterlaufen – du musst keine eigene Persistenz (Cache, Datenbank, Session, …) schreiben. + +- **Problem 3: Package-Tools im Web initialisieren** + Viele Packages, die `Spatie\LaravelPackageTools` verwenden, registrieren ihre publishable Ressourcen (Config, Views, Migrations, Assets, …) nur im CLI-Kontext. + `WebCommandRunner` verwendet Reflection, um intern an das `package`-Objekt eines solchen Service Providers zu kommen und die `publishes(...)`-Registrierung auch im Web nachzuholen. + **Ergebnis:** Befehle wie `vendor:publish` funktionieren im Browser genauso zuverlässig wie in der CLI, obwohl Laravel dort eigentlich nicht im Console-Modus läuft. + +**Wichtig:** +Reflection wird nur in diesen internen Klassen des Packages verwendet, nicht in deinen Commands. +Deine Commands bleiben normale Laravel-Commands – du musst nur: + +- von `FlowCommand` erben, +- Properties für den State definieren, +- Steps in `promptFlowSteps()` auflisten, +- `step*`-Methoden schreiben (am besten ein Prompt pro Step). + +Den Rest (Reflection, State, Web-Flow) übernimmt das Package für dich. + +### Gibt es Alternativen ohne Reflection? + +Ja – theoretisch könnten wir auf Reflection verzichten, aber das hätte Nachteile für dich als Nutzer: + +- Für **Argumente & Optionen** könnten wir eine eigene API einführen (statt `argument()/option()`), oder erzwingen, dass du alles manuell über Properties/Arrays verwaltest. Das wäre weniger “Laravel-typisch” und schwerer zu verstehen. +- Für den **Command-State zwischen Steps** könnten wir dich z.B. eine Methode wie `flowContextKeys()` implementieren lassen, in der du alle zu speichernden Properties auflistest, oder dich zwingen, selbst Cache/DB/Session zu benutzen. Das wäre mehr Boilerplate und eine zusätzliche Fehlerquelle. +- Für **Spatie Package Tools im Web** bräuchten wir entweder Änderungen im Spatie-Package selbst oder eine eigene, manuelle Konfiguration aller publishbaren Pfade – beides würde die Einrichtung deutlich komplizierter machen. + +Aus diesen Gründen kapseln wir die Reflection-Nutzung bewusst im Package und halten die API für deine Commands so einfach wie möglich. + +## Command Execution Logging + +Alle Commands, die über das Web-Interface ausgeführt werden, werden automatisch in der Datenbank geloggt. Du kannst die Ausführungen in der Filament-Resource "Command Executions" einsehen. + +### Status + +Jede Command-Ausführung hat einen der folgenden Status: + +- **`running`**: Der Command läuft gerade +- **`completed`**: Der Command wurde erfolgreich abgeschlossen +- **`failed`**: Der Command ist mit einem Fehler fehlgeschlagen +- **`cancelled`**: Der Command wurde vom Benutzer abgebrochen + +### Gespeicherte Informationen + +Für jede Ausführung werden folgende Daten gespeichert: + +- **Basis-Informationen**: Command-Name, Beschreibung, Status, Zeitstempel +- **Steps**: Liste aller Steps, die ausgeführt wurden +- **Step-Outputs**: Output jedes einzelnen Steps (als JSON) +- **Context**: Alle Command-Properties (z.B. `$environment`, `$projectName`, etc.) +- **Fehler-Informationen**: Bei `failed` Status: Fehlermeldung und der Step, bei dem der Fehler aufgetreten ist (`failed_at_step`) +- **Abbruch-Informationen**: Bei `cancelled` Status: Der Step, bei dem abgebrochen wurde (`cancelled_at_step`) +- **Benutzer**: Polymorphe Beziehung zu dem Benutzer, der den Command gestartet hat (`created_by`) + +### Migration ausführen + +Um das Logging zu aktivieren, führe die Migration aus: + +```bash +php artisan migrate +``` + +Die Migration erstellt die Tabelle `command_executions` mit allen notwendigen Feldern. + +### Filament Resource + +Die Filament-Resource "Command Executions" ist automatisch im Filament-Navigation-Menü verfügbar (falls aktiviert). Dort kannst du: + +- Alle vergangenen Command-Ausführungen einsehen +- Nach Status filtern +- Details zu jeder Ausführung ansehen (Steps, Outputs, Context, etc.) +- Fehlgeschlagene oder abgebrochene Commands analysieren + +Die Resource zeigt auch an, bei welchem Step ein Command fehlgeschlagen (`failed_at_step`) oder abgebrochen (`cancelled_at_step`) wurde. + ## License Siehe [LICENSE.md](LICENSE.md) diff --git a/packages/prompts/config/prompts.php b/packages/prompts/config/prompts.php index 84670b0ca..595dd912d 100644 --- a/packages/prompts/config/prompts.php +++ b/packages/prompts/config/prompts.php @@ -31,7 +31,24 @@ 'allowed_commands' => [ 'prompts:project-setup', + 'prompts:test-failed', // Add more commands here as needed ], + /* + |-------------------------------------------------------------------------- + | Navigation Group + |-------------------------------------------------------------------------- + | + | The navigation group where the Command Runner and Command Executions + | will appear in the Filament navigation. Common options: + | - 'System' (default) + | - 'Jobs' + | - 'Tools' + | - null (no group) + | + */ + + 'navigation_group' => 'System', + ]; diff --git a/packages/prompts/database/migrations/create_command_executions_table.php.stub b/packages/prompts/database/migrations/create_command_executions_table.php.stub new file mode 100644 index 000000000..85a0d6b8f --- /dev/null +++ b/packages/prompts/database/migrations/create_command_executions_table.php.stub @@ -0,0 +1,42 @@ +id(); + $table->string('flow_id')->unique(); + $table->string('command_name'); + $table->string('command_description')->nullable(); + $table->enum('status', ['cancelled', 'completed', 'failed'])->default('running'); + $table->timestamp('cancelled_at')->nullable(); + $table->string('cancelled_at_step')->nullable(); + $table->timestamp('started_at'); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->string('failed_at_step')->nullable(); + $table->text('error_message')->nullable(); + $table->json('steps')->nullable(); + $table->json('step_outputs')->nullable(); + $table->json('context')->nullable(); + $table->nullableMorphs('created_by'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('command_executions'); + } +}; diff --git a/packages/prompts/resources/lang/de/prompts.php b/packages/prompts/resources/lang/de/prompts.php index 57b5bc76f..d95930aad 100644 --- a/packages/prompts/resources/lang/de/prompts.php +++ b/packages/prompts/resources/lang/de/prompts.php @@ -18,15 +18,38 @@ 'select_command_placeholder' => 'Bitte Command auswählen …', 'commands_config_hint' => 'Nur Commands aus der Konfiguration sind hier sichtbar.', 'start_command_button' => 'Command starten', - 'back_to_selection' => 'Zurück zur Command-Auswahl', + 'back_to_selection' => 'Abbrechen und zur Command-Auswahl zurückkehren', + 'start_new_command' => 'Neuen Command starten', 'unknown_error' => 'Unbekannter Fehler', 'navigation_label' => 'Command Runner', 'navigation_group' => 'System', + 'executions_navigation_label' => 'Command Ausführungen', + 'command_name' => 'Command Name', + 'command_description' => 'Beschreibung', + 'status' => 'Status', + 'status_running' => 'Läuft', + 'status_completed' => 'Abgeschlossen', + 'status_failed' => 'Fehlgeschlagen', + 'status_cancelled' => 'Abgebrochen', + 'started_at' => 'Gestartet am', + 'completed_at' => 'Abgeschlossen am', + 'failed_at' => 'Fehlgeschlagen am', + 'failed_at_step' => 'Fehlgeschlagen bei Step', + 'cancelled_at' => 'Abgebrochen am', + 'cancelled_at_step' => 'Abgebrochen bei Step', + 'error_message' => 'Fehlermeldung', + 'user' => 'Benutzer', + 'basic_information' => 'Grundinformationen', + 'details' => 'Details', + 'context' => 'Kontext', + 'steps' => 'Schritte', ], 'errors' => [ 'command_not_found' => 'Command nicht gefunden: :command', 'step_not_found' => 'Step :step nicht gefunden auf Command :class', + 'command_not_allowed' => 'Command ":command" darf nicht über die Web-Oberfläche ausgeführt werden.', + 'flow_access_denied' => 'Sie haben keinen Zugriff auf diese Command-Ausführung.', ], 'validation' => [ diff --git a/packages/prompts/resources/lang/en/prompts.php b/packages/prompts/resources/lang/en/prompts.php index f8502bb35..3a6ffefe2 100644 --- a/packages/prompts/resources/lang/en/prompts.php +++ b/packages/prompts/resources/lang/en/prompts.php @@ -18,15 +18,38 @@ 'select_command_placeholder' => 'Please select a command …', 'commands_config_hint' => 'Only commands from the configuration are visible here.', 'start_command_button' => 'Start command', - 'back_to_selection' => 'Back to command selection', + 'back_to_selection' => 'Cancel and back to command selection', + 'start_new_command' => 'Start new command', 'unknown_error' => 'Unknown error', 'navigation_label' => 'Command Runner', 'navigation_group' => 'System', + 'executions_navigation_label' => 'Command Executions', + 'command_name' => 'Command Name', + 'command_description' => 'Description', + 'status' => 'Status', + 'status_running' => 'Running', + 'status_completed' => 'Completed', + 'status_failed' => 'Failed', + 'status_cancelled' => 'Cancelled', + 'started_at' => 'Started At', + 'completed_at' => 'Completed At', + 'failed_at' => 'Failed At', + 'failed_at_step' => 'Failed At Step', + 'cancelled_at' => 'Cancelled At', + 'cancelled_at_step' => 'Cancelled At Step', + 'error_message' => 'Error Message', + 'user' => 'User', + 'basic_information' => 'Basic Information', + 'details' => 'Details', + 'context' => 'Context', + 'steps' => 'Steps', ], 'errors' => [ 'command_not_found' => 'Command not found: :command', 'step_not_found' => 'Step :step not found on command :class', + 'command_not_allowed' => 'Command ":command" is not allowed to be executed through the web interface.', + 'flow_access_denied' => 'You do not have access to this command execution.', ], 'validation' => [ diff --git a/packages/prompts/resources/views/filament/components/run-command.blade.php b/packages/prompts/resources/views/filament/components/run-command.blade.php index 5c249c8c4..4147767ed 100644 --- a/packages/prompts/resources/views/filament/components/run-command.blade.php +++ b/packages/prompts/resources/views/filament/components/run-command.blade.php @@ -14,7 +14,7 @@ @endif
    @elseif($isComplete) - + {{ __('moox-prompts::prompts.ui.success_heading') }} @@ -24,45 +24,48 @@ @endif @elseif($currentPrompt) - {{ $this->form }} +
    + {{ $this->form }} - @if(!empty($validationErrors)) -
    -
    - - - - - - {{ __('moox-prompts::prompts.ui.validation_title') }} - + @if(!empty($validationErrors)) +
    +
    + + + + + + {{ __('moox-prompts::prompts.ui.validation_title') }} + +
    +
      + @foreach($validationErrors as $msg) +
    • {{ $msg }}
    • + @endforeach +
    -
      - @foreach($validationErrors as $msg) -
    • {{ $msg }}
    • - @endforeach -
    + @endif + +
    + + {{ __('moox-prompts::prompts.ui.next_button') }} +
    - @endif -
    - - {{ __('moox-prompts::prompts.ui.next_button') }} - + @if($currentStepOutput) + + + {{ __('moox-prompts::prompts.ui.output_heading') }} + +
    {{ $currentStepOutput }}
    +
    + @endif
    - - @if($currentStepOutput) - - - {{ __('moox-prompts::prompts.ui.output_heading') }} - -
    {{ $currentStepOutput }}
    -
    - @endif @else diff --git a/packages/prompts/resources/views/filament/pages/run-command.blade.php b/packages/prompts/resources/views/filament/pages/run-command.blade.php index a2dea0d4e..512b59c1f 100644 --- a/packages/prompts/resources/views/filament/pages/run-command.blade.php +++ b/packages/prompts/resources/views/filament/pages/run-command.blade.php @@ -1,7 +1,7 @@ @if (!$started)
    - + @if (empty($availableCommands))

    {{ __('moox-prompts::prompts.ui.no_commands_available') }} @@ -9,51 +9,58 @@ config/prompts.php .

    - @else + @else
    - - + + @foreach ($availableCommands as $commandName => $description) - @endforeach - - + @endforeach + +
    - +

    {{ __('moox-prompts::prompts.ui.commands_config_hint') }}

    - + {{ __('moox-prompts::prompts.ui.start_command_button') }} - -
    -
    - @endif -
    + +
    + + @endif +
    @else
    - @livewire('moox-prompts.filament.components.run-command-component', [ - 'command' => $selectedCommand, - 'commandInput' => [], - ], key('run-command-' . $selectedCommand)) + @livewire('moox-prompts.filament.components.run-command-component', [ + 'command' => $selectedCommand, + 'commandInput' => [], + ], 'run-command-' . $selectedCommand) - -
    - - {{ __('moox-prompts::prompts.ui.back_to_selection') }} - + +
    + + {{ $this->getButtonText() }} +
    @endif diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 9b2e38529..678ebe8d1 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -35,8 +35,6 @@ class RunCommandComponent extends Component implements HasForms public string $currentStepOutput = ''; - public string $lastOutput = ''; - public bool $isComplete = false; public array $validationErrors = []; @@ -53,6 +51,8 @@ class RunCommandComponent extends Component implements HasForms protected PromptResponseStore $responseStore; + protected $listeners = ['cancel-command' => 'cancel']; + public function boot(): void { $this->responseStore = new PromptResponseStore; @@ -68,11 +68,8 @@ public function mount(string $command = '', array $commandInput = []): void $this->currentPrompt = null; $this->output = ''; $this->currentStepOutput = ''; - $this->lastOutput = ''; $this->validationErrors = []; - $this->executionOutputHashes = []; $this->error = null; - $this->commandStarted = false; $this->executionStep = 0; $this->flowId = null; @@ -110,6 +107,22 @@ protected function runCommand(): void $this->currentStepOutput = ''; $state = $runner->start($this->command, $this->commandInput); $this->flowId = $state->flowId; + } else { + // Security: Validate user has access to this flow + if (! $this->hasAccessToFlow($state)) { + $this->error = __('moox-prompts::prompts.errors.flow_access_denied'); + $this->flowId = null; + + return; + } + + // Security: Validate command is still allowed (config might have changed) + if (! $this->isCommandAllowed($state->commandName)) { + $this->error = __('moox-prompts::prompts.errors.command_not_allowed', ['command' => $state->commandName]); + $this->flowId = null; + + return; + } } // Mirror answers into ResponseStore (without manipulating the counter) @@ -150,6 +163,9 @@ protected function runCommand(): void $this->answers = []; $this->data = []; $this->flowId = null; + + // Dispatch event to parent page - use window event so parent can listen + $this->dispatch('command-completed'); return; } @@ -705,6 +721,96 @@ protected function createFieldFromPrompt(string $promptId, string $method, array }; } + public function cancel(): void + { + if ($this->flowId) { + // Security: Validate user has access to this flow before cancelling + $stateStore = app(PromptFlowStateStore::class); + $state = $stateStore->get($this->flowId); + if ($state && $this->hasAccessToFlow($state)) { + $stateStore->reset($this->flowId); + } + $this->flowId = null; + } + + $this->resetComponentState(); + } + + /** + * Check if a command is in the allowed commands list. + */ + protected function isCommandAllowed(string $commandName): bool + { + $allowedCommands = config('prompts.allowed_commands', []); + + if (empty($allowedCommands)) { + return false; + } + + return in_array($commandName, $allowedCommands, true); + } + + /** + * Check if the current user has access to a flow. + * Users can only access flows they created (if CommandExecution exists), + * or flows without a CommandExecution record (legacy/ongoing flows). + */ + protected function hasAccessToFlow(\Moox\Prompts\Support\PromptFlowState $state): bool + { + // If no CommandExecution exists yet, allow access (flow just started) + if (! class_exists(\Moox\Prompts\Models\CommandExecution::class)) { + return true; + } + + try { + $execution = \Moox\Prompts\Models\CommandExecution::where('flow_id', $state->flowId)->first(); + + // If no execution record exists, allow access (legacy flow or just started) + if (! $execution) { + return true; + } + + // If execution has no creator, allow access (system/anonymous flow) + if (! $execution->createdBy) { + return true; + } + + // Check if current user is the creator + $user = \Illuminate\Support\Facades\Auth::user(); + if (! $user) { + return false; + } + + return $execution->createdBy->is($user); + } catch (\Throwable $e) { + // On error, deny access for security + \Log::warning('Error checking flow access', [ + 'flow_id' => $state->flowId, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + protected function resetComponentState(): void + { + $this->command = ''; + $this->commandInput = []; + $this->currentPrompt = null; + $this->output = ''; + $this->currentStepOutput = ''; + $this->validationErrors = []; + $this->isComplete = false; + $this->error = null; + $this->answers = []; + $this->data = []; + $this->executionStep = 0; + $this->flowId = null; + $this->responseStore->clear(); + $this->responseStore->resetCounter(); + } + public function render() { return view('moox-prompts::filament.components.run-command'); diff --git a/packages/prompts/src/Filament/Pages/RunCommandPage.php b/packages/prompts/src/Filament/Pages/RunCommandPage.php index 1261d0260..dc8904003 100644 --- a/packages/prompts/src/Filament/Pages/RunCommandPage.php +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -38,11 +38,20 @@ public static function getNavigationGroup(): ?string public array $availableCommands = []; public bool $started = false; + + public bool $commandCompleted = false; + + protected $listeners = ['command-completed' => 'onCommandCompleted']; public function mount(): void { $this->availableCommands = $this->getAvailableCommands(); } + + public function onCommandCompleted(): void + { + $this->commandCompleted = true; + } public function startCommand(): void { @@ -55,6 +64,34 @@ public function resetCommand(): void { $this->started = false; $this->selectedCommand = ''; + $this->commandCompleted = false; + } + + public function getButtonText(): string + { + if ($this->commandCompleted) { + return __('moox-prompts::prompts.ui.start_new_command'); + } + + return __('moox-prompts::prompts.ui.back_to_selection'); + } + + public function getButtonColor(): string + { + if ($this->commandCompleted) { + return 'primary'; + } + + return 'warning'; + } + + public function getButtonKey(): string + { + if ($this->commandCompleted) { + return 'new'; + } + + return 'back'; } protected function getAvailableCommands(): array diff --git a/packages/prompts/src/Filament/PromptsPlugin.php b/packages/prompts/src/Filament/PromptsPlugin.php index 988a5558a..79fb4d292 100644 --- a/packages/prompts/src/Filament/PromptsPlugin.php +++ b/packages/prompts/src/Filament/PromptsPlugin.php @@ -5,6 +5,7 @@ use Filament\Contracts\Plugin; use Filament\Panel; use Moox\Prompts\Filament\Pages\RunCommandPage; +use Moox\Prompts\Filament\Resources\CommandExecutionResource; class PromptsPlugin implements Plugin { @@ -15,9 +16,13 @@ public function getId(): string public function register(Panel $panel): void { - $panel->pages([ - RunCommandPage::class, - ]); + $panel + ->pages([ + RunCommandPage::class, + ]) + ->resources([ + CommandExecutionResource::class, + ]); } public function boot(Panel $panel): void diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php new file mode 100644 index 000000000..6dad26196 --- /dev/null +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -0,0 +1,195 @@ +components([ + Section::make(__('moox-prompts::prompts.ui.basic_information')) + ->components([ + TextInput::make('command_name') + ->label(__('moox-prompts::prompts.ui.command_name')) + ->required() + ->maxLength(255), + TextInput::make('command_description') + ->label(__('moox-prompts::prompts.ui.command_description')) + ->maxLength(255), + Select::make('status') + ->label(__('moox-prompts::prompts.ui.status')) + ->options([ + 'running' => __('moox-prompts::prompts.ui.status_running'), + 'completed' => __('moox-prompts::prompts.ui.status_completed'), + 'failed' => __('moox-prompts::prompts.ui.status_failed'), + 'cancelled' => __('moox-prompts::prompts.ui.status_cancelled'), + ]) + ->required(), + DateTimePicker::make('started_at') + ->label(__('moox-prompts::prompts.ui.started_at')) + ->required(), + DateTimePicker::make('completed_at') + ->label(__('moox-prompts::prompts.ui.completed_at')), + DateTimePicker::make('failed_at') + ->label(__('moox-prompts::prompts.ui.failed_at')), + TextInput::make('failed_at_step') + ->label(__('moox-prompts::prompts.ui.failed_at_step')) + ->disabled(), + DateTimePicker::make('cancelled_at') + ->label(__('moox-prompts::prompts.ui.cancelled_at')), + TextInput::make('cancelled_at_step') + ->label(__('moox-prompts::prompts.ui.cancelled_at_step')) + ->disabled(), + ]) + ->columns(2), + Section::make(__('moox-prompts::prompts.ui.details')) + ->components([ + Textarea::make('error_message') + ->label(__('moox-prompts::prompts.ui.error_message')) + ->rows(3), + KeyValue::make('context') + ->label(__('moox-prompts::prompts.ui.context')) + ->disabled(), + KeyValue::make('steps') + ->label(__('moox-prompts::prompts.ui.steps')) + ->disabled(), + ]) + ->collapsible(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('command_name') + ->label(__('moox-prompts::prompts.ui.command_name')) + ->searchable() + ->sortable(), + TextColumn::make('command_description') + ->label(__('moox-prompts::prompts.ui.command_description')) + ->searchable() + ->limit(50), + BadgeColumn::make('status') + ->label(__('moox-prompts::prompts.ui.status')) + ->colors([ + 'warning' => 'running', + 'success' => 'completed', + 'danger' => 'failed', + 'gray' => 'cancelled', + ]) + ->formatStateUsing(fn (string $state): string => match ($state) { + 'running' => __('moox-prompts::prompts.ui.status_running'), + 'completed' => __('moox-prompts::prompts.ui.status_completed'), + 'failed' => __('moox-prompts::prompts.ui.status_failed'), + 'cancelled' => __('moox-prompts::prompts.ui.status_cancelled'), + default => $state, + }), + TextColumn::make('createdBy.name') + ->label(__('moox-prompts::prompts.ui.user')) + ->searchable() + ->sortable() + ->formatStateUsing(fn ($record) => $record->createdBy ? $record->createdBy->name : '-'), + TextColumn::make('started_at') + ->label(__('moox-prompts::prompts.ui.started_at')) + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->label(__('moox-prompts::prompts.ui.completed_at')) + ->dateTime() + ->sortable(), + TextColumn::make('failed_at') + ->label(__('moox-prompts::prompts.ui.failed_at')) + ->dateTime() + ->sortable(), + TextColumn::make('failed_at_step') + ->label(__('moox-prompts::prompts.ui.failed_at_step')) + ->searchable() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('cancelled_at') + ->label(__('moox-prompts::prompts.ui.cancelled_at')) + ->dateTime() + ->sortable(), + TextColumn::make('cancelled_at_step') + ->label(__('moox-prompts::prompts.ui.cancelled_at_step')) + ->searchable() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('moox-prompts::prompts.ui.status')) + ->options([ + 'running' => __('moox-prompts::prompts.ui.status_running'), + 'completed' => __('moox-prompts::prompts.ui.status_completed'), + 'failed' => __('moox-prompts::prompts.ui.status_failed'), + 'cancelled' => __('moox-prompts::prompts.ui.status_cancelled'), + ]), + ]) + ->actions([ + ViewAction::make(), + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('started_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCommandExecutions::route('/'), + 'view' => Pages\ViewCommandExecution::route('/{record}'), + ]; + } +} + diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php new file mode 100644 index 000000000..791ec5ae4 --- /dev/null +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php @@ -0,0 +1,12 @@ + 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'steps' => 'array', + 'step_outputs' => 'array', + 'context' => 'array', + ]; + + public function createdBy() + { + return $this->morphTo(); + } +} + diff --git a/packages/prompts/src/PromptsServiceProvider.php b/packages/prompts/src/PromptsServiceProvider.php index 150373cb9..e06f07eb5 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -19,7 +19,8 @@ public function configureMoox(Package $package): void ->name('moox-prompts') ->hasConfigFile('prompts') ->hasViews() - ->hasTranslations(); + ->hasTranslations() + ->hasMigrations(['create_command_executions_table']); } public function register() diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index 0f48c2bea..6b7b00509 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -4,6 +4,8 @@ use Illuminate\Console\OutputStyle; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Support\Facades\Auth; +use Moox\Prompts\Models\CommandExecution; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Throwable; @@ -26,7 +28,11 @@ public function start(string $commandName, array $commandInput): PromptFlowState $steps = ['handle']; } - return $this->stateStore->create($commandName, array_values($steps)); + $state = $this->stateStore->create($commandName, array_values($steps)); + + // Don't save execution record at start - only when completed, failed, or cancelled + + return $state; } public function get(string $flowId): ?PromptFlowState @@ -78,6 +84,13 @@ public function runNext( $state->markStepFinished($step, $stepOutput); $this->stateStore->put($state); + // Update execution record if completed + if ($state->completed) { + $this->updateExecutionCompleted($state); + } else { + $this->updateExecution($state); + } + return [ 'output' => $stepOutput, 'prompt' => null, @@ -105,6 +118,9 @@ public function runNext( $this->captureCommandContext($command, $state); $this->stateStore->put($state); + // Update execution record as failed + $this->updateExecutionFailed($state, $e->getMessage()); + return [ 'output' => $this->appendExceptionToOutput($stepOutput, $e), 'prompt' => null, @@ -116,6 +132,109 @@ public function runNext( } } + protected function ensureExecutionExists(PromptFlowState $state, $command): void + { + if (! class_exists(CommandExecution::class)) { + return; + } + + try { + $exists = CommandExecution::where('flow_id', $state->flowId)->exists(); + if (! $exists) { + $execution = new CommandExecution([ + 'flow_id' => $state->flowId, + 'command_name' => $state->commandName, + 'command_description' => $command->getDescription(), + 'status' => 'cancelled', // Will be updated by updateExecutionCompleted/Failed + 'started_at' => now(), + 'steps' => $state->steps, + 'step_outputs' => $state->stepOutputs ?? [], + 'context' => $state->context ?? [], + ]); + + if (Auth::check()) { + $execution->createdBy()->associate(Auth::user()); + } + + $execution->save(); + } + } catch (\Throwable $e) { + // Log error for debugging + \Log::error('Failed to ensure command execution exists', [ + 'error' => $e->getMessage(), + 'flow_id' => $state->flowId ?? null, + ]); + } + } + + protected function updateExecution(PromptFlowState $state): void + { + if (! class_exists(CommandExecution::class)) { + return; + } + + try { + CommandExecution::where('flow_id', $state->flowId)->update([ + 'step_outputs' => $state->stepOutputs, + 'context' => $state->context, + ]); + } catch (\Throwable $e) { + // Silently fail if table doesn't exist yet + } + } + + protected function updateExecutionCompleted(PromptFlowState $state): void + { + if (! class_exists(CommandExecution::class)) { + return; + } + + try { + $command = $this->resolveCommand($state->commandName); + $this->ensureExecutionExists($state, $command); + + CommandExecution::where('flow_id', $state->flowId)->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'step_outputs' => $state->stepOutputs, + 'context' => $state->context, + ]); + } catch (\Throwable $e) { + // Log error for debugging + \Log::error('Failed to update command execution as completed', [ + 'error' => $e->getMessage(), + 'flow_id' => $state->flowId ?? null, + ]); + } + } + + protected function updateExecutionFailed(PromptFlowState $state, string $errorMessage): void + { + if (! class_exists(CommandExecution::class)) { + return; + } + + try { + $command = $this->resolveCommand($state->commandName); + $this->ensureExecutionExists($state, $command); + + CommandExecution::where('flow_id', $state->flowId)->update([ + 'status' => 'failed', + 'failed_at' => now(), + 'failed_at_step' => $state->failedAt, // The step where the failure occurred + 'error_message' => $errorMessage, + 'step_outputs' => $state->stepOutputs, + 'context' => $state->context, + ]); + } catch (\Throwable $e) { + // Log error for debugging + \Log::error('Failed to update command execution as failed', [ + 'error' => $e->getMessage(), + 'flow_id' => $state->flowId ?? null, + ]); + } + } + protected function appendExceptionToOutput(string $output, Throwable $e): string { $trace = $e->getTraceAsString(); diff --git a/packages/prompts/src/Support/PromptFlowStateStore.php b/packages/prompts/src/Support/PromptFlowStateStore.php index 54a7b89e4..68a908b16 100644 --- a/packages/prompts/src/Support/PromptFlowStateStore.php +++ b/packages/prompts/src/Support/PromptFlowStateStore.php @@ -34,7 +34,76 @@ public function put(PromptFlowState $state): void public function reset(string $flowId): void { + // Get state BEFORE deleting it from cache + $state = $this->get($flowId); + + // Delete from cache $this->cache->forget($this->key($flowId)); + + // Mark execution as cancelled if it exists, or create one + if (class_exists(\Moox\Prompts\Models\CommandExecution::class)) { + try { + // Get the current step that was being executed + $cancelledAtStep = null; + if ($state) { + // The current step is the one that was about to be executed (nextPendingStep) + // or if we're in the middle of a step, it's the current one + $currentStep = $state->nextPendingStep(); + if ($currentStep) { + $cancelledAtStep = $currentStep; + } elseif ($state->currentIndex > 0 && $state->currentIndex <= count($state->steps)) { + // If no pending step but we have a currentIndex, we were at this step + $cancelledAtStep = $state->steps[$state->currentIndex - 1] ?? null; + } elseif ($state->currentIndex > 0) { + // Fallback: use the last step in the array + $cancelledAtStep = $state->steps[count($state->steps) - 1] ?? null; + } + } + + // Try to update existing record - only if it's not already completed or failed + $updated = \Moox\Prompts\Models\CommandExecution::where('flow_id', $flowId) + ->whereNotIn('status', ['cancelled', 'completed', 'failed']) + ->update([ + 'status' => 'cancelled', + 'cancelled_at' => now(), + 'cancelled_at_step' => $cancelledAtStep, + 'step_outputs' => $state->stepOutputs ?? [], + 'context' => $state->context ?? [], + ]); + + // If no record exists yet, create one with cancelled status + if ($updated === 0 && $state) { + $command = app(\Illuminate\Contracts\Console\Kernel::class)->all()[$state->commandName] ?? null; + if ($command) { + $execution = new \Moox\Prompts\Models\CommandExecution([ + 'flow_id' => $flowId, + 'command_name' => $state->commandName, + 'command_description' => $command->getDescription(), + 'status' => 'cancelled', + 'started_at' => now(), + 'cancelled_at' => now(), + 'cancelled_at_step' => $cancelledAtStep, + 'steps' => $state->steps ?? [], + 'step_outputs' => $state->stepOutputs ?? [], + 'context' => $state->context ?? [], + ]); + + if (\Illuminate\Support\Facades\Auth::check()) { + $execution->createdBy()->associate(\Illuminate\Support\Facades\Auth::user()); + } + + $execution->save(); + } + } + } catch (\Throwable $e) { + // Log error for debugging + \Log::error('Failed to mark command execution as cancelled', [ + 'error' => $e->getMessage(), + 'flow_id' => $flowId, + 'trace' => $e->getTraceAsString(), + ]); + } + } } protected function key(string $flowId): string From 6eadc930f8effbe20cb514b72e75ab34de45fb6d Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:56:54 +0000 Subject: [PATCH 12/16] Fix styling --- .../Components/RunCommandComponent.php | 26 +++++++++---------- .../src/Filament/Pages/RunCommandPage.php | 18 ++++++------- .../Resources/CommandExecutionResource.php | 21 +++++++-------- .../Pages/ListCommandExecutions.php | 1 - .../Pages/ViewCommandExecution.php | 1 - .../prompts/src/Models/CommandExecution.php | 1 - .../prompts/src/Support/PromptFlowRunner.php | 8 +++--- .../src/Support/PromptFlowStateStore.php | 12 ++++----- 8 files changed, 41 insertions(+), 47 deletions(-) diff --git a/packages/prompts/src/Filament/Components/RunCommandComponent.php b/packages/prompts/src/Filament/Components/RunCommandComponent.php index 678ebe8d1..76d653992 100644 --- a/packages/prompts/src/Filament/Components/RunCommandComponent.php +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -112,15 +112,15 @@ protected function runCommand(): void if (! $this->hasAccessToFlow($state)) { $this->error = __('moox-prompts::prompts.errors.flow_access_denied'); $this->flowId = null; - + return; } - + // Security: Validate command is still allowed (config might have changed) if (! $this->isCommandAllowed($state->commandName)) { $this->error = __('moox-prompts::prompts.errors.command_not_allowed', ['command' => $state->commandName]); $this->flowId = null; - + return; } } @@ -163,7 +163,7 @@ protected function runCommand(): void $this->answers = []; $this->data = []; $this->flowId = null; - + // Dispatch event to parent page - use window event so parent can listen $this->dispatch('command-completed'); @@ -732,7 +732,7 @@ public function cancel(): void } $this->flowId = null; } - + $this->resetComponentState(); } @@ -742,11 +742,11 @@ public function cancel(): void protected function isCommandAllowed(string $commandName): bool { $allowedCommands = config('prompts.allowed_commands', []); - + if (empty($allowedCommands)) { return false; } - + return in_array($commandName, $allowedCommands, true); } @@ -761,26 +761,26 @@ protected function hasAccessToFlow(\Moox\Prompts\Support\PromptFlowState $state) if (! class_exists(\Moox\Prompts\Models\CommandExecution::class)) { return true; } - + try { $execution = \Moox\Prompts\Models\CommandExecution::where('flow_id', $state->flowId)->first(); - + // If no execution record exists, allow access (legacy flow or just started) if (! $execution) { return true; } - + // If execution has no creator, allow access (system/anonymous flow) if (! $execution->createdBy) { return true; } - + // Check if current user is the creator $user = \Illuminate\Support\Facades\Auth::user(); if (! $user) { return false; } - + return $execution->createdBy->is($user); } catch (\Throwable $e) { // On error, deny access for security @@ -788,7 +788,7 @@ protected function hasAccessToFlow(\Moox\Prompts\Support\PromptFlowState $state) 'flow_id' => $state->flowId, 'error' => $e->getMessage(), ]); - + return false; } } diff --git a/packages/prompts/src/Filament/Pages/RunCommandPage.php b/packages/prompts/src/Filament/Pages/RunCommandPage.php index dc8904003..555d8d1ee 100644 --- a/packages/prompts/src/Filament/Pages/RunCommandPage.php +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -38,16 +38,16 @@ public static function getNavigationGroup(): ?string public array $availableCommands = []; public bool $started = false; - + public bool $commandCompleted = false; - + protected $listeners = ['command-completed' => 'onCommandCompleted']; public function mount(): void { $this->availableCommands = $this->getAvailableCommands(); } - + public function onCommandCompleted(): void { $this->commandCompleted = true; @@ -66,31 +66,31 @@ public function resetCommand(): void $this->selectedCommand = ''; $this->commandCompleted = false; } - + public function getButtonText(): string { if ($this->commandCompleted) { return __('moox-prompts::prompts.ui.start_new_command'); } - + return __('moox-prompts::prompts.ui.back_to_selection'); } - + public function getButtonColor(): string { if ($this->commandCompleted) { return 'primary'; } - + return 'warning'; } - + public function getButtonKey(): string { if ($this->commandCompleted) { return 'new'; } - + return 'back'; } diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php index 6dad26196..9d9a3b9e8 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -2,26 +2,24 @@ namespace Moox\Prompts\Filament\Resources; -use Filament\Forms; -use Filament\Tables; -use Filament\Tables\Table; -use Filament\Schemas\Schema; -use Filament\Actions\ViewAction; -use Filament\Resources\Resource; -use Filament\Actions\DeleteAction; use Filament\Actions\BulkActionGroup; -use Filament\Forms\Components\Select; +use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ViewAction; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; -use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\TextInput; +use Filament\Resources\Resource; use Filament\Schemas\Components\Section; +use Filament\Schemas\Schema; use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Moox\Prompts\Models\CommandExecution; -use Filament\Forms\Components\DateTimePicker; +use Filament\Tables\Table; use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; +use Moox\Prompts\Models\CommandExecution; class CommandExecutionResource extends Resource { @@ -192,4 +190,3 @@ public static function getPages(): array ]; } } - diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php index 791ec5ae4..ef05a3459 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php @@ -9,4 +9,3 @@ class ListCommandExecutions extends ListRecords { protected static string $resource = CommandExecutionResource::class; } - diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php index c22b4478b..a4b9e8503 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php @@ -16,4 +16,3 @@ protected function getHeaderActions(): array ]; } } - diff --git a/packages/prompts/src/Models/CommandExecution.php b/packages/prompts/src/Models/CommandExecution.php index d16043134..4da05e95d 100644 --- a/packages/prompts/src/Models/CommandExecution.php +++ b/packages/prompts/src/Models/CommandExecution.php @@ -40,4 +40,3 @@ public function createdBy() return $this->morphTo(); } } - diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index 6b7b00509..d434008e7 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -151,11 +151,11 @@ protected function ensureExecutionExists(PromptFlowState $state, $command): void 'step_outputs' => $state->stepOutputs ?? [], 'context' => $state->context ?? [], ]); - + if (Auth::check()) { $execution->createdBy()->associate(Auth::user()); } - + $execution->save(); } } catch (\Throwable $e) { @@ -192,7 +192,7 @@ protected function updateExecutionCompleted(PromptFlowState $state): void try { $command = $this->resolveCommand($state->commandName); $this->ensureExecutionExists($state, $command); - + CommandExecution::where('flow_id', $state->flowId)->update([ 'status' => 'completed', 'completed_at' => now(), @@ -217,7 +217,7 @@ protected function updateExecutionFailed(PromptFlowState $state, string $errorMe try { $command = $this->resolveCommand($state->commandName); $this->ensureExecutionExists($state, $command); - + CommandExecution::where('flow_id', $state->flowId)->update([ 'status' => 'failed', 'failed_at' => now(), diff --git a/packages/prompts/src/Support/PromptFlowStateStore.php b/packages/prompts/src/Support/PromptFlowStateStore.php index 68a908b16..fc51146df 100644 --- a/packages/prompts/src/Support/PromptFlowStateStore.php +++ b/packages/prompts/src/Support/PromptFlowStateStore.php @@ -36,10 +36,10 @@ public function reset(string $flowId): void { // Get state BEFORE deleting it from cache $state = $this->get($flowId); - + // Delete from cache $this->cache->forget($this->key($flowId)); - + // Mark execution as cancelled if it exists, or create one if (class_exists(\Moox\Prompts\Models\CommandExecution::class)) { try { @@ -59,7 +59,7 @@ public function reset(string $flowId): void $cancelledAtStep = $state->steps[count($state->steps) - 1] ?? null; } } - + // Try to update existing record - only if it's not already completed or failed $updated = \Moox\Prompts\Models\CommandExecution::where('flow_id', $flowId) ->whereNotIn('status', ['cancelled', 'completed', 'failed']) @@ -70,7 +70,7 @@ public function reset(string $flowId): void 'step_outputs' => $state->stepOutputs ?? [], 'context' => $state->context ?? [], ]); - + // If no record exists yet, create one with cancelled status if ($updated === 0 && $state) { $command = app(\Illuminate\Contracts\Console\Kernel::class)->all()[$state->commandName] ?? null; @@ -87,11 +87,11 @@ public function reset(string $flowId): void 'step_outputs' => $state->stepOutputs ?? [], 'context' => $state->context ?? [], ]); - + if (\Illuminate\Support\Facades\Auth::check()) { $execution->createdBy()->associate(\Illuminate\Support\Facades\Auth::user()); } - + $execution->save(); } } From fd29a431d5f9261a11fd6a65a82cebe819dabab6 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:34:42 +0100 Subject: [PATCH 13/16] visible conditions --- .../Resources/CommandExecutionResource.php | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php index 9d9a3b9e8..eebf25424 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -2,24 +2,25 @@ namespace Moox\Prompts\Filament\Resources; -use Filament\Actions\BulkActionGroup; +use Filament\Tables\Table; +use Filament\Schemas\Schema; +use Filament\Actions\ViewAction; +use Filament\Resources\Resource; use Filament\Actions\DeleteAction; +use Filament\Actions\BulkActionGroup; +use Filament\Forms\Components\Select; use Filament\Actions\DeleteBulkAction; -use Filament\Actions\ViewAction; -use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; +use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\TextInput; -use Filament\Resources\Resource; use Filament\Schemas\Components\Section; -use Filament\Schemas\Schema; use Filament\Tables\Columns\BadgeColumn; -use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Filament\Tables\Table; -use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; use Moox\Prompts\Models\CommandExecution; +use Filament\Forms\Components\DateTimePicker; +use Filament\Schemas\Components\Utilities\Get; +use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; class CommandExecutionResource extends Resource { @@ -65,17 +66,25 @@ public static function form(Schema $schema): Schema ->label(__('moox-prompts::prompts.ui.started_at')) ->required(), DateTimePicker::make('completed_at') - ->label(__('moox-prompts::prompts.ui.completed_at')), + ->label(__('moox-prompts::prompts.ui.completed_at')) + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'completed'), DateTimePicker::make('failed_at') - ->label(__('moox-prompts::prompts.ui.failed_at')), + ->label(__('moox-prompts::prompts.ui.failed_at')) + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'failed'), TextInput::make('failed_at_step') ->label(__('moox-prompts::prompts.ui.failed_at_step')) - ->disabled(), + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'failed'), DateTimePicker::make('cancelled_at') - ->label(__('moox-prompts::prompts.ui.cancelled_at')), + ->label(__('moox-prompts::prompts.ui.cancelled_at')) + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'cancelled'), TextInput::make('cancelled_at_step') ->label(__('moox-prompts::prompts.ui.cancelled_at_step')) - ->disabled(), + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'cancelled'), ]) ->columns(2), Section::make(__('moox-prompts::prompts.ui.details')) From 0850032cbbfa0685c67e5d89bc4f4d27d90b6e26 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:35:37 +0000 Subject: [PATCH 14/16] Fix styling --- .../Resources/CommandExecutionResource.php | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php index eebf25424..bc1abab18 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -2,25 +2,25 @@ namespace Moox\Prompts\Filament\Resources; -use Filament\Tables\Table; -use Filament\Schemas\Schema; -use Filament\Actions\ViewAction; -use Filament\Resources\Resource; -use Filament\Actions\DeleteAction; use Filament\Actions\BulkActionGroup; -use Filament\Forms\Components\Select; +use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ViewAction; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; -use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\TextInput; +use Filament\Resources\Resource; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Schema; use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Moox\Prompts\Models\CommandExecution; -use Filament\Forms\Components\DateTimePicker; -use Filament\Schemas\Components\Utilities\Get; +use Filament\Tables\Table; use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; +use Moox\Prompts\Models\CommandExecution; class CommandExecutionResource extends Resource { From 861c35006fc0fc564bb219a91094ea0aa9fd198b Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:29:04 +0100 Subject: [PATCH 15/16] error message display --- .../prompts/resources/lang/de/prompts.php | 6 ++++ .../prompts/resources/lang/en/prompts.php | 6 ++++ .../Resources/CommandExecutionResource.php | 32 +++++++++++-------- .../Pages/ViewCommandExecution.php | 13 +++++++- .../prompts/src/Support/PromptFlowRunner.php | 10 ++++-- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/prompts/resources/lang/de/prompts.php b/packages/prompts/resources/lang/de/prompts.php index d95930aad..cefcd7679 100644 --- a/packages/prompts/resources/lang/de/prompts.php +++ b/packages/prompts/resources/lang/de/prompts.php @@ -43,6 +43,12 @@ 'details' => 'Details', 'context' => 'Kontext', 'steps' => 'Schritte', + 'step_outputs' => 'Step Ausgaben', + 'no_step_outputs' => 'Keine Step Ausgaben verfügbar', + 'view_error' => 'Fehler anzeigen', + 'close' => 'Schließen', + 'no_error_message' => 'Keine Fehlermeldung verfügbar', + 'copy_error' => 'Fehler kopieren', ], 'errors' => [ diff --git a/packages/prompts/resources/lang/en/prompts.php b/packages/prompts/resources/lang/en/prompts.php index 3a6ffefe2..e0cde610e 100644 --- a/packages/prompts/resources/lang/en/prompts.php +++ b/packages/prompts/resources/lang/en/prompts.php @@ -43,6 +43,12 @@ 'details' => 'Details', 'context' => 'Context', 'steps' => 'Steps', + 'step_outputs' => 'Step Outputs', + 'no_step_outputs' => 'No step outputs available', + 'view_error' => 'View Error', + 'close' => 'Close', + 'no_error_message' => 'No error message available', + 'copy_error' => 'Copy Error', ], 'errors' => [ diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php index bc1abab18..eabe7e24f 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -2,25 +2,27 @@ namespace Moox\Prompts\Filament\Resources; -use Filament\Actions\BulkActionGroup; +use Filament\Tables\Table; +use Filament\Schemas\Schema; +use Filament\Actions\ViewAction; +use Filament\Resources\Resource; use Filament\Actions\DeleteAction; +use Filament\Actions\BulkActionGroup; +use Filament\Forms\Components\Select; +use Filament\Schemas\Components\View; use Filament\Actions\DeleteBulkAction; -use Filament\Actions\ViewAction; -use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; +use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\TextInput; -use Filament\Resources\Resource; use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\Utilities\Get; -use Filament\Schemas\Schema; + use Filament\Tables\Columns\BadgeColumn; -use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Filament\Tables\Table; -use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; use Moox\Prompts\Models\CommandExecution; +use Filament\Forms\Components\DateTimePicker; +use Filament\Schemas\Components\Utilities\Get; +use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; class CommandExecutionResource extends Resource { @@ -89,9 +91,6 @@ public static function form(Schema $schema): Schema ->columns(2), Section::make(__('moox-prompts::prompts.ui.details')) ->components([ - Textarea::make('error_message') - ->label(__('moox-prompts::prompts.ui.error_message')) - ->rows(3), KeyValue::make('context') ->label(__('moox-prompts::prompts.ui.context')) ->disabled(), @@ -100,6 +99,13 @@ public static function form(Schema $schema): Schema ->disabled(), ]) ->collapsible(), + Section::make(__('moox-prompts::prompts.ui.step_outputs')) + ->components([ + KeyValue::make('step_outputs') + ->label(__('moox-prompts::prompts.ui.step_outputs')) + ->disabled(), + ]) + ->collapsible(), ]); } diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php index a4b9e8503..6865232e6 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php @@ -2,7 +2,9 @@ namespace Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; +use Filament\Actions\Action; use Filament\Resources\Pages\ViewRecord; +use Illuminate\Support\HtmlString; use Moox\Prompts\Filament\Resources\CommandExecutionResource; class ViewCommandExecution extends ViewRecord @@ -12,7 +14,16 @@ class ViewCommandExecution extends ViewRecord protected function getHeaderActions(): array { return [ - // + Action::make('view_error') + ->label(__('moox-prompts::prompts.ui.view_error')) + ->icon('heroicon-o-exclamation-triangle') + ->color('danger') + ->modalHeading(__('moox-prompts::prompts.ui.error_message')) + ->modalContent(fn () => new HtmlString('
    ' . e($this->record->error_message ?? __('moox-prompts::prompts.ui.no_error_message')) . '
    ')) + ->modalWidth('screen') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('moox-prompts::prompts.ui.close')) + ->visible(fn () => !empty($this->record->error_message)), ]; } } diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index d434008e7..5df341b5b 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -119,7 +119,7 @@ public function runNext( $this->stateStore->put($state); // Update execution record as failed - $this->updateExecutionFailed($state, $e->getMessage()); + $this->updateExecutionFailed($state, $e); return [ 'output' => $this->appendExceptionToOutput($stepOutput, $e), @@ -208,7 +208,7 @@ protected function updateExecutionCompleted(PromptFlowState $state): void } } - protected function updateExecutionFailed(PromptFlowState $state, string $errorMessage): void + protected function updateExecutionFailed(PromptFlowState $state, Throwable $exception): void { if (! class_exists(CommandExecution::class)) { return; @@ -218,11 +218,15 @@ protected function updateExecutionFailed(PromptFlowState $state, string $errorMe $command = $this->resolveCommand($state->commandName); $this->ensureExecutionExists($state, $command); + // Build full error message with stack trace + $errorMessage = $this->formatThrowableMessage($exception); + $fullError = $errorMessage . "\n\n" . $exception->getTraceAsString(); + CommandExecution::where('flow_id', $state->flowId)->update([ 'status' => 'failed', 'failed_at' => now(), 'failed_at_step' => $state->failedAt, // The step where the failure occurred - 'error_message' => $errorMessage, + 'error_message' => $fullError, 'step_outputs' => $state->stepOutputs, 'context' => $state->context, ]); From 8e37d7628b9f1612b4a6fdff98bf81ea575878e0 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:29:46 +0000 Subject: [PATCH 16/16] Fix styling --- .../Resources/CommandExecutionResource.php | 23 ++++++++----------- .../Pages/ViewCommandExecution.php | 4 ++-- .../prompts/src/Support/PromptFlowRunner.php | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php index eabe7e24f..3f2ff5417 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -2,27 +2,24 @@ namespace Moox\Prompts\Filament\Resources; -use Filament\Tables\Table; -use Filament\Schemas\Schema; -use Filament\Actions\ViewAction; -use Filament\Resources\Resource; -use Filament\Actions\DeleteAction; use Filament\Actions\BulkActionGroup; -use Filament\Forms\Components\Select; -use Filament\Schemas\Components\View; +use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ViewAction; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Textarea; -use Filament\Tables\Columns\TextColumn; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Resources\Resource; use Filament\Schemas\Components\Section; - +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Schema; use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Moox\Prompts\Models\CommandExecution; -use Filament\Forms\Components\DateTimePicker; -use Filament\Schemas\Components\Utilities\Get; +use Filament\Tables\Table; use Moox\Prompts\Filament\Resources\CommandExecutionResource\Pages; +use Moox\Prompts\Models\CommandExecution; class CommandExecutionResource extends Resource { diff --git a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php index 6865232e6..c568938aa 100644 --- a/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ViewCommandExecution.php @@ -19,11 +19,11 @@ protected function getHeaderActions(): array ->icon('heroicon-o-exclamation-triangle') ->color('danger') ->modalHeading(__('moox-prompts::prompts.ui.error_message')) - ->modalContent(fn () => new HtmlString('
    ' . e($this->record->error_message ?? __('moox-prompts::prompts.ui.no_error_message')) . '
    ')) + ->modalContent(fn () => new HtmlString('
    '.e($this->record->error_message ?? __('moox-prompts::prompts.ui.no_error_message')).'
    ')) ->modalWidth('screen') ->modalSubmitAction(false) ->modalCancelActionLabel(__('moox-prompts::prompts.ui.close')) - ->visible(fn () => !empty($this->record->error_message)), + ->visible(fn () => ! empty($this->record->error_message)), ]; } } diff --git a/packages/prompts/src/Support/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php index 5df341b5b..b1b2872e6 100644 --- a/packages/prompts/src/Support/PromptFlowRunner.php +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -220,7 +220,7 @@ protected function updateExecutionFailed(PromptFlowState $state, Throwable $exce // Build full error message with stack trace $errorMessage = $this->formatThrowableMessage($exception); - $fullError = $errorMessage . "\n\n" . $exception->getTraceAsString(); + $fullError = $errorMessage."\n\n".$exception->getTraceAsString(); CommandExecution::where('flow_id', $state->flowId)->update([ 'status' => 'failed',