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/README.md b/packages/prompts/README.md index 2507e6b79..b57bda5b2 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -1,59 +1,238 @@ # Moox Prompts -CLI-kompatible Prompts für Laravel Artisan Commands. +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. + +## 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 -## Übersicht +```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). -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. +Den Rest (Reflection, State, Web-Flow) übernimmt das Package für dich. -## Features +### Gibt es Alternativen ohne Reflection? -- ✅ Alle Laravel Prompt-Typen unterstützt (`text`, `select`, `multiselect`, `confirm`, etc.) -- ✅ Identische API wie Laravel Prompts +Ja – theoretisch könnten wir auf Reflection verzichten, aber das hätte Nachteile für dich als Nutzer: -## Installation +- 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 -composer require moox/prompts +php artisan migrate ``` -## 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... -} -``` +Die Migration erstellt die Tabelle `command_executions` mit allen notwendigen Feldern. + +### Filament Resource -## Architektur +Die Filament-Resource "Command Executions" ist automatisch im Filament-Navigation-Menü verfügbar (falls aktiviert). Dort kannst du: -Das Package besteht aus: +- Alle vergangenen Command-Ausführungen einsehen +- Nach Status filtern +- Details zu jeder Ausführung ansehen (Steps, Outputs, Context, etc.) +- Fehlgeschlagene oder abgebrochene Commands analysieren -- **PromptRuntime**: Interface für Prompt-Implementierungen -- **CliPromptRuntime**: CLI-Implementierung (delegiert an Laravel Prompts) -- **functions.php**: Globale Helper-Funktionen -- **PromptsServiceProvider**: Registriert Services +Die Resource zeigt auch an, bei welchem Step ein Command fehlgeschlagen (`failed_at_step`) oder abgebrochen (`cancelled_at_step`) wurde. ## License diff --git a/packages/prompts/config/prompts.php b/packages/prompts/config/prompts.php index 425331ef9..595dd912d 100644 --- a/packages/prompts/config/prompts.php +++ b/packages/prompts/config/prompts.php @@ -2,55 +2,53 @@ /* |-------------------------------------------------------------------------- -| 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, + /* + |-------------------------------------------------------------------------- + | Allowed Commands + |-------------------------------------------------------------------------- + | + | 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', + | ], + | + */ - '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, - ], - ], - ], - ], - ], + 'allowed_commands' => [ + 'prompts:project-setup', + 'prompts:test-failed', + // Add more commands here as needed ], - 'relations' => [], - /* |-------------------------------------------------------------------------- - | Navigation + | Navigation Group |-------------------------------------------------------------------------- | - | The navigation group and sort of the Resource, - | and if the panel is enabled. + | 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) | */ - 'auth' => [ - 'user' => 'Moox\\DevTools\\Models\\TestUser', - ], - 'navigation_group' => 'DEV', + + '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 a5e40ea3a..cefcd7679 100644 --- a/packages/prompts/resources/lang/de/prompts.php +++ b/packages/prompts/resources/lang/de/prompts.php @@ -3,4 +3,67 @@ 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' => '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', + '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' => [ + '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' => [ + '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..e0cde610e 100644 --- a/packages/prompts/resources/lang/en/prompts.php +++ b/packages/prompts/resources/lang/en/prompts.php @@ -3,4 +3,67 @@ 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' => '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', + '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' => [ + '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' => [ + '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 new file mode 100644 index 000000000..4147767ed --- /dev/null +++ b/packages/prompts/resources/views/filament/components/run-command.blade.php @@ -0,0 +1,77 @@ +
+ @if($error) + + + {{ __('moox-prompts::prompts.ui.error_heading') }} + +

{{ $error }}

+ @if($output) +
{{ $output }}
+ @elseif($currentStepOutput) +
{{ $currentStepOutput }}
+ @endif +
+ @elseif($isComplete) + + + {{ __('moox-prompts::prompts.ui.success_heading') }} + + @if($output) +
{{ $output }}
+ @endif +
+ @elseif($currentPrompt) +
+ {{ $this->form }} + + @if(!empty($validationErrors)) +
+
+ + + + + + {{ __('moox-prompts::prompts.ui.validation_title') }} + +
+
    + @foreach($validationErrors as $msg) +
  • {{ $msg }}
  • + @endforeach +
+
+ @endif + +
+ + {{ __('moox-prompts::prompts.ui.next_button') }} + +
+ + @if($currentStepOutput) + + + {{ __('moox-prompts::prompts.ui.output_heading') }} + +
{{ $currentStepOutput }}
+
+ @endif +
+ @else + + + {{ __('moox-prompts::prompts.ui.starting_heading') }} + + + + @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..512b59c1f --- /dev/null +++ b/packages/prompts/resources/views/filament/pages/run-command.blade.php @@ -0,0 +1,67 @@ + + @if (!$started) +
+ + @if (empty($availableCommands)) +

+ {{ __('moox-prompts::prompts.ui.no_commands_available') }} + + config/prompts.php + . +

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

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

+ + + {{ __('moox-prompts::prompts.ui.start_command_button') }} + +
+
+ @endif +
+
+ @else +
+ + @livewire('moox-prompts.filament.components.run-command-component', [ + 'command' => $selectedCommand, + 'commandInput' => [], + ], 'run-command-' . $selectedCommand) + + +
+ + {{ $this->getButtonText() }} + +
+
+ @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..76d653992 --- /dev/null +++ b/packages/prompts/src/Filament/Components/RunCommandComponent.php @@ -0,0 +1,818 @@ + 'cancel']; + + public function boot(): void + { + $this->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->currentStepOutput = ''; + $this->validationErrors = []; + $this->error = null; + $this->executionStep = 0; + $this->flowId = null; + + $this->responseStore->clear(); + + if ($command) { + $this->runCommand(); + } + } + + protected function runCommand(): void + { + $this->error = null; + $this->isComplete = false; + + try { + // Force web runtime (not CLI) in web context + app()->instance(PromptRuntime::class, new WebPromptRuntime); + WebCommandRunner::ensurePublishableResourcesRegistered(); + + $runner = app(PromptFlowRunner::class); + $stateStore = app(PromptFlowStateStore::class); + + $state = $this->flowId ? $stateStore->get($this->flowId) : null; + if (! $state) { + // Fresh flow: clear ResponseStore and local states + $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; + } 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) + foreach ($this->answers as $promptId => $answer) { + $this->responseStore->set($promptId, $answer); + } + + app()->instance('moox.prompts.response_store', $this->responseStore); + + while (true) { + $result = $runner->runNext($state, $this->commandInput, $this->responseStore); + $this->appendOutput($result['output'] ?? ''); + $state = $result['state']; + + if (! empty($result['prompt'])) { + $this->currentStepOutput = $this->output ?? ''; + $this->currentPrompt = $result['prompt']; + $this->executionStep++; + $this->prefillPromptForm($result['prompt']); + + return; + } + + if (! empty($result['failed'])) { + $this->currentStepOutput = $this->output; + $this->error = $result['error'] ?? __('moox-prompts::prompts.ui.unknown_error'); + $this->currentPrompt = null; + + return; + } + + 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; + + // Dispatch event to parent page - use window event so parent can listen + $this->dispatch('command-completed'); + + return; + } + } + } catch (Throwable $e) { + $this->output = $this->appendExceptionToOutput($this->output, $e); + $this->currentStepOutput = $this->output; + $this->error = $this->formatThrowableMessage($e); + $this->currentPrompt = null; + } + } + + 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) { + return; + } + + $method = $prompt['method'] ?? ''; + $params = $prompt['params'] ?? []; + $p = PromptParamsHelper::extract($method, $params); + + // If an answer already exists, use it + 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; + } + + // Otherwise use default value from prompt params + 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) + // For multiselect we need to fill the individual checkboxes + $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; + } + + $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 + { + 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) { + return; + } + + $promptId = $this->currentPrompt['id'] ?? null; + if (! $promptId) { + return; + } + + try { + $this->validationErrors = []; + + 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) { + $errors = $e->errors(); + $this->validationErrors = []; + + if (isset($errors[$promptId])) { + $this->validationErrors = is_array($errors[$promptId]) + ? $errors[$promptId] + : [$errors[$promptId]]; + } + + 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 ($this->currentPrompt['method'] === 'confirm') { + if ($answer === null) { + $this->validatePromptAnswer($promptId, null, $this->currentPrompt); + + return; + } + } elseif ($this->currentPrompt['method'] === 'select') { + if ($answer === null || $answer === '' || $answer === '0') { + $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); + + return; + } + } elseif ($answer === null || $answer === '') { + $this->validatePromptAnswer($promptId, '', $this->currentPrompt); + + 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 ($answer === 'yes') { + $answer = true; + } elseif ($answer === 'no') { + $answer = false; + } elseif (! is_bool($answer)) { + $answer = (bool) $answer; + } + } + + $this->validatePromptAnswer($promptId, $answer, $this->currentPrompt); + if (! empty($this->validationErrors)) { + return; + } + + $this->answers[$promptId] = $answer; + $this->responseStore->set($promptId, $answer); + $this->currentPrompt = null; + $this->runCommand(); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } + } + + protected function validatePromptAnswer(string $promptId, mixed $answer, array $prompt): void + { + $method = $prompt['method'] ?? ''; + $params = $prompt['params'] ?? []; + + $p = PromptParamsHelper::extract($method, $params); + + $rules = []; + $messages = []; + + $requiredFlag = match ($method) { + 'text', 'textarea', 'password' => $p['required'] ?? false, + 'multiselect' => $p['required'] ?? false, + 'confirm' => $p['required'] ?? false, + default => false, + }; + + if ($method === 'multiselect' && ! empty($p['options'] ?? [])) { + $requiredFlag = true; + } + + $validate = match ($method) { + 'text', 'textarea', 'password' => $p['validate'] ?? null, + 'select' => $p['validate'] ?? null, + 'multiselect' => $p['validate'] ?? null, + default => null, + }; + + $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' && $requiredFlag !== false) { + $rules[] = 'required'; + } + + if ($method === 'select' && $requiredFlag !== false) { + $rules[] = 'in:'.implode(',', array_keys($p['options'] ?? [])); + } + + $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[] = __('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"] = __('moox-prompts::prompts.validation.multiselect_required'); + $messages["{$promptId}.min"] = __('moox-prompts::prompts.validation.multiselect_min'); + } + + if ($method === 'select') { + $messages["{$promptId}.required"] = __('moox-prompts::prompts.validation.select_required'); + $messages["{$promptId}.in"] = __('moox-prompts::prompts.validation.select_in'); + } + + $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']; + } + + 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 + { + $p = PromptParamsHelper::extract('multiselect', $params); + + $label = $p['label'] ?? ''; + $required = ($p['required'] ?? false) !== false; + // Default value: first from answers, then from default parameter + $defaultValue = $this->answers[$promptId] ?? ($p['default'] ?? []); + $options = $p['options'] ?? []; + + $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 + { + $p = PromptParamsHelper::extract($method, $params); + + $label = $p['label'] ?? ''; + $required = match ($method) { + '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 = $p['options'] ?? []; + $defaultSelect = $defaultValue ?? ($p['default'] ?? null); + + // 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); + } + + $validate = match ($method) { + 'text', 'textarea', 'password' => $p['validate'] ?? null, + 'select' => $p['validate'] ?? null, + 'multiselect' => $p['validate'] ?? null, + default => null, + }; + + $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'; + } + + if ($method === 'select' && $required && ! empty($options)) { + $rules[] = 'in:'.implode(',', array_keys($options)); + } + + $pushRules($rules, $validate); + + return match ($method) { + 'text' => TextInput::make($promptId) + ->label($label) + ->placeholder($p['placeholder'] ?? '') + ->default($defaultValue ?? $p['default'] ?? '') + ->rules($rules) + ->hint($p['hint'] ?? null) + ->live(onBlur: false), + + 'textarea' => Textarea::make($promptId) + ->label($label) + ->placeholder($p['placeholder'] ?? '') + ->default($defaultValue ?? $p['default'] ?? '') + ->rules($rules) + ->rows(5) + ->hint($p['hint'] ?? null), + + 'password' => TextInput::make($promptId) + ->label($label) + ->password() + ->placeholder($p['placeholder'] ?? '') + ->default($defaultValue ?? '') + ->rules($rules) + ->hint($p['hint'] ?? null) + ->live(onBlur: false), + + 'select' => Select::make($promptId) + ->label($label) + ->options($options) + ->default($defaultSelect !== null ? $defaultSelect : null) + ->rules($rules) + ->selectablePlaceholder(false) + ->hint($p['hint'] ?? null) + ->live(onBlur: false), + + 'multiselect' => null, + + 'confirm' => Radio::make($promptId) + ->label($label) + ->options([ + 'yes' => __('moox-prompts::prompts.ui.confirm_yes'), + 'no' => __('moox-prompts::prompts.ui.confirm_no'), + ]) + ->default($confirmDefault !== null ? ($confirmDefault ? 'yes' : 'no') : null) + ->rules($rules) + ->hint($p['hint'] ?? null) + ->live(onBlur: false), + + default => null, + }; + } + + 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 new file mode 100644 index 000000000..555d8d1ee --- /dev/null +++ b/packages/prompts/src/Filament/Pages/RunCommandPage.php @@ -0,0 +1,119 @@ + 'onCommandCompleted']; + + public function mount(): void + { + $this->availableCommands = $this->getAvailableCommands(); + } + + public function onCommandCompleted(): void + { + $this->commandCompleted = true; + } + + public function startCommand(): void + { + if ($this->selectedCommand) { + $this->started = true; + } + } + + 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 + { + $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..79fb4d292 --- /dev/null +++ b/packages/prompts/src/Filament/PromptsPlugin.php @@ -0,0 +1,42 @@ +pages([ + RunCommandPage::class, + ]) + ->resources([ + CommandExecutionResource::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/Filament/Resources/CommandExecutionResource.php b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php new file mode 100644 index 000000000..3f2ff5417 --- /dev/null +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource.php @@ -0,0 +1,204 @@ +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')) + ->disabled() + ->visible(fn (Get $get): bool => $get('status') === 'completed'), + DateTimePicker::make('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() + ->visible(fn (Get $get): bool => $get('status') === 'failed'), + DateTimePicker::make('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() + ->visible(fn (Get $get): bool => $get('status') === 'cancelled'), + ]) + ->columns(2), + Section::make(__('moox-prompts::prompts.ui.details')) + ->components([ + KeyValue::make('context') + ->label(__('moox-prompts::prompts.ui.context')) + ->disabled(), + KeyValue::make('steps') + ->label(__('moox-prompts::prompts.ui.steps')) + ->disabled(), + ]) + ->collapsible(), + Section::make(__('moox-prompts::prompts.ui.step_outputs')) + ->components([ + KeyValue::make('step_outputs') + ->label(__('moox-prompts::prompts.ui.step_outputs')) + ->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..ef05a3459 --- /dev/null +++ b/packages/prompts/src/Filament/Resources/CommandExecutionResource/Pages/ListCommandExecutions.php @@ -0,0 +1,11 @@ +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/Models/CommandExecution.php b/packages/prompts/src/Models/CommandExecution.php new file mode 100644 index 000000000..4da05e95d --- /dev/null +++ b/packages/prompts/src/Models/CommandExecution.php @@ -0,0 +1,42 @@ + '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 170c19457..e06f07eb5 100644 --- a/packages/prompts/src/PromptsServiceProvider.php +++ b/packages/prompts/src/PromptsServiceProvider.php @@ -4,7 +4,11 @@ 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; use Spatie\LaravelPackageTools\Package; class PromptsServiceProvider extends MooxServiceProvider @@ -13,15 +17,48 @@ public function configureMoox(Package $package): void { $package ->name('moox-prompts') - ->hasConfigFile('prompts'); + ->hasConfigFile('prompts') + ->hasViews() + ->hasTranslations() + ->hasMigrations(['create_command_executions_table']); } public function register() { parent::register(); + $this->app->bind('moox.prompts.response_store', function ($app) { + 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) { - return new CliPromptRuntime; + if (php_sapi_name() === 'cli') { + return new CliPromptRuntime; + } + + return new WebPromptRuntime; }); } @@ -30,5 +67,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/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..e98730efe --- /dev/null +++ b/packages/prompts/src/Support/FlowCommand.php @@ -0,0 +1,37 @@ +promptFlowSteps() as $step) { + if (method_exists($this, $step)) { + $this->{$step}(); + } + } + + return self::SUCCESS; + } +} diff --git a/packages/prompts/src/Support/PendingPromptsException.php b/packages/prompts/src/Support/PendingPromptsException.php new file mode 100644 index 000000000..584c261c8 --- /dev/null +++ b/packages/prompts/src/Support/PendingPromptsException.php @@ -0,0 +1,26 @@ +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/PromptFlowRunner.php b/packages/prompts/src/Support/PromptFlowRunner.php new file mode 100644 index 000000000..b1b2872e6 --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowRunner.php @@ -0,0 +1,334 @@ +resolveCommand($commandName); + $steps = ($command instanceof PromptFlowCommand) + ? $command->promptFlowSteps() + : ['handle']; + + if (empty($steps)) { + $steps = ['handle']; + } + + $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 + { + 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); + // 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 + $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); + + // Update execution record if completed + if ($state->completed) { + $this->updateExecutionCompleted($state); + } else { + $this->updateExecution($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); + + // Update execution record as failed + $this->updateExecutionFailed($state, $e); + + return [ + 'output' => $this->appendExceptionToOutput($stepOutput, $e), + 'prompt' => null, + 'completed' => false, + 'failed' => true, + 'error' => $e->getMessage(), + 'state' => $state, + ]; + } + } + + 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, Throwable $exception): void + { + if (! class_exists(CommandExecution::class)) { + return; + } + + try { + $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' => $fullError, + '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(); + + 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(__('moox-prompts::prompts.errors.command_not_found', ['command' => $commandName])); + } + + return $commandInstance; + } + + protected function invokeStep($command, string $method): void + { + if (! method_exists($command, $method)) { + throw new \RuntimeException(__('moox-prompts::prompts.errors.step_not_found', [ + 'step' => $method, + 'class' => 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); + + // 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()) { + // Only properties of the concrete command class, not from the base class + 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..873e1246e --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowState.php @@ -0,0 +1,42 @@ +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..fc51146df --- /dev/null +++ b/packages/prompts/src/Support/PromptFlowStateStore.php @@ -0,0 +1,113 @@ +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 + { + // 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 + { + return $this->prefix.$flowId; + } +} diff --git a/packages/prompts/src/Support/PromptParamsHelper.php b/packages/prompts/src/Support/PromptParamsHelper.php new file mode 100644 index 000000000..6537f0b87 --- /dev/null +++ b/packages/prompts/src/Support/PromptParamsHelper.php @@ -0,0 +1,96 @@ + [ + '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 => [], + }; + } + + /** + * Returns a single parameter. + * + * @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 + { + $extracted = self::extract($method, $params); + + return $extracted[$paramName] ?? $default; + } +} diff --git a/packages/prompts/src/Support/PromptResponseStore.php b/packages/prompts/src/Support/PromptResponseStore.php new file mode 100644 index 000000000..4f4f7569f --- /dev/null +++ b/packages/prompts/src/Support/PromptResponseStore.php @@ -0,0 +1,51 @@ +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/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/WebCommandRunner.php b/packages/prompts/src/Support/WebCommandRunner.php new file mode 100644 index 000000000..e135f3a8b --- /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); + } +} diff --git a/packages/prompts/src/Support/WebPromptRuntime.php b/packages/prompts/src/Support/WebPromptRuntime.php new file mode 100644 index 000000000..c9937aa13 --- /dev/null +++ b/packages/prompts/src/Support/WebPromptRuntime.php @@ -0,0 +1,354 @@ +responseStore = app('moox.prompts.response_store'); + } + + protected function generatePromptId(string $method): string + { + // 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 for generic usage (e.g. CLI or without flow context) + 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; + } + + // 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)); + } + + 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'); + } +} 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();