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 @@ +
{{ $error }}
+ @if($output) +{{ $output }}
+ @elseif($currentStepOutput)
+ {{ $currentStepOutput }}
+ @endif
+ {{ $output }}
+ @endif
+ {{ $currentStepOutput }}
+
+ {{ __('moox-prompts::prompts.ui.no_commands_available') }}
+
+ config/prompts.php
+ .
+
'.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();