diff --git a/AGENTS.md b/AGENTS.md index 8b5392103..bdeb45a96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ vue/src/ # Frontend source ## PATTERNS TO ALWAYS USE 2. **Use Traits**: ManageMedias, HasMedias, MediasTrait etc. -3. **Register in ServiceProvider**: Every new feature if necessary +3. **Feature ServiceProvider**: New feature singletons/bindings go in a dedicated `src/Providers/{Feature}ServiceProvider.php` (e.g. `RemoteApiServiceProvider`, `ArtisanRunnerServiceProvider`, `CoverageServiceProvider`) — register it from `ModularousProvider::$providers`. Do **not** dump feature bindings into `BaseServiceProvider`. 4. **Write Tests**: tests/$FOLDERNAME 5. **Type Hints**: Always use PHP 8.1+ type hints 6. **Config-Driven**: Use config('modularous.xxx') (under merges folder) @@ -45,10 +45,22 @@ vue/src/ # Frontend source - Use Vuetify 3 components (not plain HTML) ## WHEN ADDING FEATURES -1. Create class in appropriate src/ subdirectory -2. Register in ModularousServiceProvider -3. Write unit + feature tests -4. Update documentation +1. Create classes under the feature folder (e.g. `src/Services/{Feature}/`) +2. Create `src/Providers/{Feature}ServiceProvider.php` for that feature’s singletons, aliases, log channels, and feature-specific boot logic +3. Register the provider in `ModularousProvider::$providers` +4. Write unit + feature tests +5. Update documentation + +## FEATURE SERVICE PROVIDERS + +| Feature | Provider | +|---------|----------| +| Coverage | `CoverageServiceProvider` | +| Security | `SecurityServiceProvider` | +| RemoteApi | `RemoteApiServiceProvider` | +| ArtisanRunner | `ArtisanRunnerServiceProvider` | + +`BaseServiceProvider` stays for package-wide core bindings only (Modularous, navigation, cache, filepond, etc.). ## FORBIDDEN - ❌ Business logic in controllers diff --git a/config/defers/navigation.php b/config/defers/navigation.php index bc3ee0940..ac41a723e 100755 --- a/config/defers/navigation.php +++ b/config/defers/navigation.php @@ -50,6 +50,11 @@ 'icon' => '$header', ], ...Navigation::systemMenu(), + '_artisan_runner' => [ + 'name' => 'Artisan Runner', + 'icon' => 'mdi-console', + 'route_name' => 'admin.artisan-runner', + ], 'media_library' => [ 'name' => 'Media Library', 'icon' => '$media', diff --git a/config/defers/ui_settings.php b/config/defers/ui_settings.php index cc882de89..1ae890381 100644 --- a/config/defers/ui_settings.php +++ b/config/defers/ui_settings.php @@ -52,7 +52,43 @@ ], 'dashboard' => [ 'blocks' => [ + 'system-console' => [ + 'widget' => 'SystemConsoleWidget', + 'widgetCol' => [ + 'cols' => 12, + 'lg' => 6, + ], + 'widgetAttributes' => [ + 'class' => 'h-50', + 'style' => 'min-height: 160px', + ], + 'allowedRoles' => ['superadmin'], + 'attributes' => [ + 'class' => 'h-100', + 'title' => 'System Console', + 'subtitle' => 'Maintenance mode and cache / optimize commands.', + 'elevation' => 2, + ], + ], + 'artisan-runner' => [ + 'widget' => 'ArtisanRunnerWidget', + 'widgetCol' => [ + 'cols' => 12, + 'lg' => 6, + ], + 'widgetAttributes' => [ + 'class' => 'h-50', + 'style' => 'min-height: 160px', + ], + 'allowedRoles' => ['superadmin'], + 'attributes' => [ + 'class' => 'h-100', + 'title' => 'Artisan Runner', + 'subtitle' => 'Select a command to configure and run.', + 'elevation' => 2, + ], + ], ], - ], + ] ]; diff --git a/config/merges/artisan_runner.php b/config/merges/artisan_runner.php new file mode 100644 index 000000000..22d83dcde --- /dev/null +++ b/config/merges/artisan_runner.php @@ -0,0 +1,65 @@ + env('MODULAROUS_ARTISAN_RUNNER_ENABLED', false), + + /** + * Who can open the ArtisanRunner panel (page + API). + * + * @var list + */ + 'allowed_roles' => array_values(array_filter(array_map( + 'trim', + explode(',', env('MODULAROUS_ARTISAN_RUNNER_ALLOWED_ROLES', 'superadmin')) + ))), + + /** + * Command allowlist for non-superadmin users in allowed_roles. + * Superadmin always bypasses this and sees/runs all Artisan commands. + * Supports exact names and globs (e.g. modularous:cache:*). + * + * SystemConsoleWidget uses down, up, route:clear, route:cache, optimize, + * and optimize:clear via the same runner. If you grant admin (non-superadmin) + * access to that widget, include those command names in this allowlist. + * + * @var list + */ + 'allowlist' => array_values(array_filter(array_map( + 'trim', + explode(',', env('MODULAROUS_ARTISAN_RUNNER_ALLOWLIST', '')) + ))), + + /** + * How ArtisanRunner executes commands: + * - auto: in-process Kernel::handle, except subprocess_commands (CLI parity) + * - in_process: always Kernel::handle (interactive prompts work) + * - subprocess: always spawn `php artisan …` (no BridgedQuestionHelper) + */ + 'execution' => env('MODULAROUS_ARTISAN_RUNNER_EXECUTION', 'auto'), + + /** + * Commands that must run as a real CLI subprocess when execution=auto. + * + * route:cache (and optimize) bootstrap a fresh app to serialize routes. Under + * PHP-FPM, runningInConsole() is false and request()/isPanelUrl() still reflect + * the panel host, so Modularous skips CMS public catch-alls and may omit module + * routes — producing an incomplete route cache vs terminal `php artisan`. + * + * Supports exact names and globs (via AllowlistMatcher). + * + * @var list + */ + 'subprocess_commands' => array_values(array_filter(array_map( + 'trim', + explode(',', env( + 'MODULAROUS_ARTISAN_RUNNER_SUBPROCESS_COMMANDS', + 'route:cache,route:clear,config:cache,config:clear,event:cache,event:clear,view:cache,view:clear,optimize,optimize:clear' + )) + ))), + + 'timeout' => (int) env('MODULAROUS_ARTISAN_RUNNER_TIMEOUT', 120), + + 'prompt_timeout' => (int) env('MODULAROUS_ARTISAN_RUNNER_PROMPT_TIMEOUT', 60), + + 'max_output_bytes' => (int) env('MODULAROUS_ARTISAN_RUNNER_MAX_OUTPUT_BYTES', 1_048_576), +]; diff --git a/config/merges/system_console.php b/config/merges/system_console.php new file mode 100644 index 000000000..17b8eee0d --- /dev/null +++ b/config/merges/system_console.php @@ -0,0 +1,75 @@ +}> + */ + 'down_presets' => [ + 'default' => [ + 'label' => 'Default maintenance', + 'options' => [ + 'render' => 'modularous::maintenance', + 'retry' => 60, + 'refresh' => 30, + 'secret' => env('MODULAROUS_MAINTENANCE_SECRET'), + ], + ], + ], + + 'default_down_preset' => 'default', + + /** + * Granular cache / optimize actions shown in SystemConsoleWidget. + * Commands must be runnable via ArtisanRunner (superadmin or allowlist). + * + * @var list + */ + 'cache_commands' => [ + 'route:clear' => [ + 'command' => 'route:clear', + 'label' => 'Route Clear', + 'confirm' => false, + 'color' => 'warning', + 'icon' => 'mdi-routes', + ], + 'route:cache' => [ + 'command' => 'route:cache', + 'label' => 'Route Cache', + 'confirm' => true, + 'color' => 'primary', + 'icon' => 'mdi-routes-clock', + ], + 'optimize:clear' => [ + 'command' => 'optimize:clear', + 'label' => 'Optimize Clear', + 'confirm' => true, + 'color' => 'warning', + 'icon' => 'mdi-cached', + ], + 'optimize' => [ + 'command' => 'optimize', + 'label' => 'Optimize', + 'confirm' => true, + 'color' => 'primary', + 'icon' => 'mdi-rocket-launch', + ], + ], +]; diff --git a/config/merges/system_settings.php b/config/merges/system_settings.php index a72105830..202307425 100644 --- a/config/merges/system_settings.php +++ b/config/merges/system_settings.php @@ -19,6 +19,7 @@ 'mail_override_enabled' => (bool) env('MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE', false), 'sensitive_keys' => [ 'smtp.password', + 'down_presets.secret', ], 'extra_inputs' => [], 'input_overrides' => [], diff --git a/config/navigation.php b/config/navigation.php index 93af6c471..900018523 100755 --- a/config/navigation.php +++ b/config/navigation.php @@ -35,6 +35,11 @@ 'icon' => '$header', ], ...Navigation::systemMenu(), + '_artisan_runner' => [ + 'name' => 'Artisan Runner', + 'icon' => 'mdi-console', + 'route_name' => 'admin.artisan-runner', + ], 'media_library' => [ 'name' => 'Media Library', 'icon' => '$media', diff --git a/docs/src/pages/system-reference/backend/facades/system-settings.md b/docs/src/pages/system-reference/backend/facades/system-settings.md index bd77bd29e..491917731 100644 --- a/docs/src/pages/system-reference/backend/facades/system-settings.md +++ b/docs/src/pages/system-reference/backend/facades/system-settings.md @@ -14,7 +14,7 @@ Backend / system source of truth for global settings. Full usage guide: [Setting ## Sections -`site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale` +`site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale`, `down_presets` ## Methods diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php index da2b5063b..447d00c22 100644 --- a/modules/Cms/Routes/web.php +++ b/modules/Cms/Routes/web.php @@ -75,12 +75,6 @@ ? 'modularous.security.step_up:' . $sitemapCommitAbility : null; - $siteSeoSave = Route::post('site-seo', [SiteSeoSettingsController::class, 'update']) - ->name('siteSeo.save'); - - if ($siteSeoStepUpMiddleware) { - $siteSeoSave->middleware($siteSeoStepUpMiddleware); - } $sitemapDryRun = Route::post('sitemap/dry-run', [SitemapController::class, 'dryRun']) ->name('sitemap.dryRun.web'); diff --git a/modules/SystemSetting/Config/config.php b/modules/SystemSetting/Config/config.php index 009c5980e..5f6512bc6 100644 --- a/modules/SystemSetting/Config/config.php +++ b/modules/SystemSetting/Config/config.php @@ -83,6 +83,50 @@ ['name' => 'ga_measurement_id', 'type' => 'text', 'label' => 'GA measurement ID', 'placeholder' => 'G-XXXXXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], ], ], + [ + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Maintenance down presets', + 'name' => 'down_presets', + 'col' => ['cols' => 12], + 'schema' => [ + [ + 'name' => 'render', + 'type' => 'text', + 'label' => 'Render view', + 'placeholder' => 'modularous::maintenance', + 'default' => 'modularous::maintenance', + 'hint' => 'Blade view passed to artisan down --render', + 'rules' => 'nullable|string|max:255', + 'col' => ['cols' => 12, 'lg' => 6], + ], + [ + 'name' => 'retry', + 'type' => 'number-input', + 'label' => 'Retry (seconds)', + 'placeholder' => 60, + 'default' => 60, + 'hint' => 'Retry-After header for clients', + 'col' => ['cols' => 12, 'lg' => 3], + ], + [ + 'name' => 'refresh', + 'type' => 'number-input', + 'label' => 'Refresh (seconds)', + 'placeholder' => 30, + 'default' => 30, + 'hint' => 'Meta refresh interval on the maintenance page', + 'col' => ['cols' => 12, 'lg' => 3], + ], + [ + 'name' => 'secret', + 'type' => 'password', + 'label' => 'Bypass secret', + 'hint' => 'Secret token for artisan down --secret (overrides MODULAROUS_MAINTENANCE_SECRET when set)', + 'rules' => 'nullable|string|max:255', + 'col' => ['cols' => 12, 'lg' => 6], + ], + ], + ], [ 'type' => '@collapsible_group', diff --git a/modules/SystemSetting/Entities/General.php b/modules/SystemSetting/Entities/General.php index ffb179366..08d1e3d5f 100644 --- a/modules/SystemSetting/Entities/General.php +++ b/modules/SystemSetting/Entities/General.php @@ -25,6 +25,7 @@ class General extends Model 'smtp', 'analytics', 'locale', + 'down_presets', ]; /** @@ -38,6 +39,7 @@ class General extends Model 'smtp' => 'array', 'analytics' => 'array', 'locale' => 'array', + 'down_presets' => 'array', 'published' => 'boolean', ]; @@ -46,6 +48,6 @@ class General extends Model */ public static function settingsSections(): array { - return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale']; + return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale', 'down_presets']; } } diff --git a/modules/SystemSetting/Repositories/GeneralRepository.php b/modules/SystemSetting/Repositories/GeneralRepository.php index aca9c5186..e49049ead 100644 --- a/modules/SystemSetting/Repositories/GeneralRepository.php +++ b/modules/SystemSetting/Repositories/GeneralRepository.php @@ -23,6 +23,7 @@ public function __construct(General $model) public function prepareFieldsBeforeCreate($fields) { $fields = $this->encryptSmtpPassword($fields); + $fields = $this->encryptDownPresetSecret($fields); return parent::prepareFieldsBeforeCreate($fields); } @@ -35,6 +36,7 @@ public function prepareFieldsBeforeCreate($fields) public function prepareFieldsBeforeSave($object, $fields) { $fields = $this->encryptSmtpPassword($fields, $object); + $fields = $this->encryptDownPresetSecret($fields, $object); return parent::prepareFieldsBeforeSave($object, $fields); } @@ -84,6 +86,34 @@ protected function encryptSmtpPassword(array $fields, mixed $object = null): arr return $fields; } + /** + * @param array $fields + * @return array + */ + protected function encryptDownPresetSecret(array $fields, mixed $object = null): array + { + $secret = Arr::get($fields, 'down_presets.secret'); + + if ($secret === null) { + return $fields; + } + + if ($secret === '') { + if ($object !== null) { + $existing = is_array($object->down_presets ?? null) ? $object->down_presets : []; + Arr::set($fields, 'down_presets.secret', $existing['secret'] ?? null); + } + + return $fields; + } + + if (is_string($secret) && ! $this->alreadyEncrypted($secret)) { + Arr::set($fields, 'down_presets.secret', encrypt($secret, false)); + } + + return $fields; + } + protected function alreadyEncrypted(string $value): bool { try { diff --git a/routes/web.php b/routes/web.php index 7755f7a5e..59938314a 100755 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,8 @@ use Unusualify\Modularous\Http\Controllers\Utility\ChatController; use Unusualify\Modularous\Http\Controllers\Utility\ProcessController; use Unusualify\Modularous\Http\Controllers\Utility\TagController; +use Unusualify\Modularous\Http\Controllers\ArtisanRunner\ArtisanRunnerController; +use Unusualify\Modularous\Http\Controllers\ArtisanRunner\ArtisanRunnerToolController; /* |-------------------------------------------------------------------------- @@ -44,6 +46,8 @@ Route::resource('', 'DashboardController', ['as' => 'dashboard', 'names' => ['index' => 'dashboard']])->only(['index']); +Route::get('artisan-runner', ArtisanRunnerToolController::class)->name('artisan-runner'); + Route::get('users/impersonate/stop', 'Utility\ImpersonateController@stopImpersonate')->name('impersonate.stop'); Route::get('users/impersonate/{id}', 'Utility\ImpersonateController@impersonate')->name('impersonate'); @@ -76,6 +80,17 @@ Route::put('{process}', [ProcessController::class, 'update'])->name('update'); }); + ############## ArtisanRunner API routes ############## + Route::group(['prefix' => 'artisan-runner', 'as' => 'artisan-runner.', 'controller' => ArtisanRunnerController::class], function () { + Route::get('commands', 'commands')->name('commands'); + Route::get('commands/{name}', 'definition') + ->where('name', '.*') + ->name('commands.show'); + Route::post('runs', 'run')->name('runs'); + Route::post('runs/{runId}/answer', 'answer')->name('runs.answer'); + }); + ############## End of ArtisanRunner API routes ############## + Route::group(['prefix' => 'tag', 'as' => 'tag.', 'controller' => TagController::class], function () { Route::get('index', 'index')->name('index'); Route::put('update', 'update')->name('update'); diff --git a/src/AGENTS.md b/src/AGENTS.md index 37e1a4b09..f2b0b5d0a 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -6,12 +6,15 @@ Format: - Action | Path | Constraints | Tests Examples: -- Add trait | src/Entities/Traits/HasVersioning.php | PHP 8.1 types, PSR-12, register in ServiceProvider | tests/Entities/HasVersioningTest.php +- Add trait | src/Entities/Traits/HasVersioning.php | PHP 8.1 types, PSR-12, register via feature ServiceProvider if needed | tests/Entities/HasVersioningTest.php - Implement service | src/Services/CoverageService.php | Use DI, return typed DTOs, no facades | tests/Services/CoverageServiceTest.php -- Update provider | src/Providers/ModularousProvider.php | Register bindings, publish config | tests/Providers/ModularousProviderTest.php +- Add feature bindings | src/Providers/{Feature}ServiceProvider.php | Singletons/aliases/log channels for that feature only; register in ModularousProvider::$providers — do not add to BaseServiceProvider | tests/Services/{Feature}/* +- Update provider | src/Providers/ModularousProvider.php | Register feature providers, publish config | tests/Providers/ModularousProviderTest.php Keep prompts short and concrete — avoid high-level product requests. Always include a target path and a test expectation. +**Feature providers:** New Modularous features that need container bindings must use a dedicated `{Feature}ServiceProvider` (see `RemoteApiServiceProvider`, `ArtisanRunnerServiceProvider`, `CoverageServiceProvider`) registered from `ModularousProvider`. Keep `BaseServiceProvider` for package-wide core only. + Core classes (short): - `Modularous.php` | Module manager extending Nwidart FileRepository. Handles scanning, caching, vendor/app paths, auth names, and helpers (e.g. `scan()`, `allEnabled()`, `getVendorPath()`). - `Module.php` | Represents a single module. Loads config/providers/commands, exposes route names, middleware aliases, paths and helpers (e.g. `getConfig()`, `getRouteNames()`, `getDirectoryPath()`). diff --git a/src/Http/Controllers/ArtisanRunner/ArtisanRunnerController.php b/src/Http/Controllers/ArtisanRunner/ArtisanRunnerController.php new file mode 100644 index 000000000..05942b5df --- /dev/null +++ b/src/Http/Controllers/ArtisanRunner/ArtisanRunnerController.php @@ -0,0 +1,131 @@ +authorizeUser($request); + + return response()->json([ + 'commands' => $this->artisanRunner->catalogForUser($user), + ]); + } + + public function definition(Request $request, string $name): JsonResponse + { + $user = $this->authorizeUser($request); + $command = rawurldecode($name); + + try { + return response()->json( + $this->artisanRunner->definitionForUser($user, $command) + ); + } catch (CommandNotAllowedException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } catch (ArtisanRunnerException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } + } + + public function run(Request $request): StreamedResponse|JsonResponse + { + $user = $this->authorizeUser($request); + + $validated = $request->validate([ + 'command' => ['required', 'string'], + 'arguments' => ['nullable', 'array'], + 'options' => ['nullable', 'array'], + ]); + + $command = $validated['command']; + $arguments = $validated['arguments'] ?? []; + $options = $validated['options'] ?? []; + $runId = (string) Str::uuid(); + + try { + $this->artisanRunner->assertCommandAllowed($user, $command); + } catch (CommandNotAllowedException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } catch (ArtisanRunnerException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } + + return response()->stream(function () use ($user, $command, $arguments, $options, $runId): void { + $emit = function (string $event, array $payload): void { + echo 'event: ' . $event . "\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n"; + + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + }; + + // Disable output buffering for live streaming. + while (ob_get_level() > 0) { + @ob_end_flush(); + } + + $this->artisanRunner->run( + $user, + $command, + $arguments, + $options, + $runId, + $emit, + ); + }, 200, [ + 'Content-Type' => 'text/event-stream; charset=UTF-8', + 'Cache-Control' => 'no-cache, no-store', + 'X-Accel-Buffering' => 'no', + 'Connection' => 'keep-alive', + ]); + } + + public function answer(Request $request, string $runId): JsonResponse + { + $this->authorizeUser($request); + + $validated = $request->validate([ + 'prompt_id' => ['required', 'string'], + 'answer' => ['nullable'], + ]); + + $this->artisanRunner->answerPrompt( + $runId, + $validated['prompt_id'], + $validated['answer'] ?? null, + ); + + return response()->json(['ok' => true]); + } + + private function authorizeUser(Request $request): \Illuminate\Contracts\Auth\Authenticatable + { + $user = $request->user(); + abort_unless($user !== null, 401); + + if (! $this->artisanRunner->userCanAccess($user)) { + abort(403, 'Artisan Runner is disabled or access denied.'); + } + + return $user; + } +} diff --git a/src/Http/Controllers/ArtisanRunner/ArtisanRunnerToolController.php b/src/Http/Controllers/ArtisanRunner/ArtisanRunnerToolController.php new file mode 100644 index 000000000..c1c5f4734 --- /dev/null +++ b/src/Http/Controllers/ArtisanRunner/ArtisanRunnerToolController.php @@ -0,0 +1,96 @@ +user(); + abort_unless($user !== null, 401); + + $enabled = modularousConfig('artisan_runner.enabled', false); + $canAccess = $enabled && $this->artisanRunner->userCanAccess($user); + + $pageTitle = __('Artisan Runner') . ' - ' . Modularous::pageTitle(); + $headerTitle = __('Artisan Runner'); + + $data = [ + 'pageTitle' => $pageTitle, + 'headerTitle' => $headerTitle, + '_mainConfiguration' => [ + 'navigation' => $this->navigationWithBreadcrumbs(), + ], + ]; + + $this->shareInertiaStoreVariables(); + + return Inertia::render('ArtisanRunner', [ + 'runnerDisabled' => ! $canAccess, + 'runnerEndpoints' => $canAccess + ? $this->artisanRunner->panelEndpoints() + : $this->artisanRunner->emptyPanelEndpoints(), + 'isSuperadmin' => (bool) ($user->is_superadmin ?? false), + 'endpoints' => new \stdClass, + 'mainConfiguration' => $this->getInertiaMainConfiguration($data), + 'headLayoutData' => $this->getHeadLayoutData($data), + ]); + } + + /** + * @return array + */ + protected function navigationWithBreadcrumbs(): array + { + $navigation = get_modularous_navigation_config(); + + $dashboardRoute = Modularous::getAdminRouteNamePrefix() . '.dashboard'; + $dashboardCrumb = [ + 'title' => __('Dashboard'), + 'disabled' => true, + ]; + + if (Route::has($dashboardRoute)) { + $dashboardCrumb['href'] = route($dashboardRoute); + $dashboardCrumb['disabled'] = false; + } + + $navigation['breadcrumbs'] = [ + $dashboardCrumb, + [ + 'title' => __('Artisan Runner'), + 'disabled' => true, + ], + ]; + + return $navigation; + } +} diff --git a/src/Providers/ArtisanRunnerServiceProvider.php b/src/Providers/ArtisanRunnerServiceProvider.php new file mode 100644 index 000000000..e9c59bc2b --- /dev/null +++ b/src/Providers/ArtisanRunnerServiceProvider.php @@ -0,0 +1,49 @@ +app->singleton(AllowlistMatcher::class); + $this->app->singleton(CommandCatalogInterface::class, CommandCatalog::class); + $this->app->singleton(CommandDefinitionInspector::class); + $this->app->singleton(InteractiveQuestionBroker::class, function () { + return new InteractiveQuestionBroker( + (int) modularousConfig('artisan_runner.prompt_timeout', 60) + ); + }); + $this->app->singleton(CommandExecutor::class); + $this->app->singleton(ArtisanRunnerInterface::class, ArtisanRunner::class); + } + + /** + * @return list + */ + public function provides(): array + { + return [ + AllowlistMatcher::class, + CommandCatalogInterface::class, + CommandDefinitionInspector::class, + InteractiveQuestionBroker::class, + CommandExecutor::class, + ArtisanRunnerInterface::class, + ]; + } +} diff --git a/src/Providers/BaseServiceProvider.php b/src/Providers/BaseServiceProvider.php index 14e90c0bb..19eac9b3d 100755 --- a/src/Providers/BaseServiceProvider.php +++ b/src/Providers/BaseServiceProvider.php @@ -40,10 +40,6 @@ use Unusualify\Modularous\Services\MigrationBackup; use Unusualify\Modularous\Services\ModularousCacheService; use Unusualify\Modularous\Services\RedirectService; -use Unusualify\Modularous\Services\RemoteApi\RemoteApiConnectorFactory; -use Unusualify\Modularous\Services\RemoteApi\RemoteApiConnectorResolver; -use Unusualify\Modularous\Services\RemoteApi\RemoteApiRateLimiter; -use Unusualify\Modularous\Services\RemoteApi\RemoteApiSynchronizer; use Unusualify\Modularous\Services\UtmParameters; use Unusualify\Modularous\Services\View\ModularousNavigation; use Unusualify\Modularous\Support\CommandDiscovery; @@ -174,11 +170,6 @@ public function register() $this->app->singleton('modularous.navigation', ModularousNavigation::class); - $this->app->singleton(RemoteApiConnectorResolver::class); - $this->app->singleton(RemoteApiRateLimiter::class); - $this->app->singleton(RemoteApiConnectorFactory::class); - $this->app->singleton(RemoteApiSynchronizer::class); - $this->app->singleton('model.relation.namespace', function () { return "Illuminate\Database\Eloquent\Relations"; }); @@ -658,13 +649,6 @@ private function addModularousLogChannels() 'level' => env('MODULAROUS_NOTIFICATION_FAILURE_LOG_LEVEL', 'error'), 'days' => 14, ]); - $this->app['config']->set('logging.channels.modularous-remote-api', [ - 'driver' => 'daily', - 'path' => storage_path('logs/modularous-remote-api.log'), - 'level' => env('MODULAROUS_REMOTE_API_LOG_LEVEL', 'info'), - 'days' => env('LOG_DAILY_DAYS', 14), - 'replace_placeholders' => true, - ]); if (! config('logging.channels.modularous-resource-cache')) { $this->app['config']->set('logging.channels.modularous-resource-cache', [ diff --git a/src/Providers/ModularousProvider.php b/src/Providers/ModularousProvider.php index ab5d02b24..d9f033356 100755 --- a/src/Providers/ModularousProvider.php +++ b/src/Providers/ModularousProvider.php @@ -16,9 +16,11 @@ class ModularousProvider extends ServiceProvider BaseServiceProvider::class, ModuleServiceProvider::class, SecurityServiceProvider::class, + RemoteApiServiceProvider::class, RouteServiceProvider::class, AuthServiceProvider::class, CoverageServiceProvider::class, + ArtisanRunnerServiceProvider::class, // AuthServiceProvider::class, // ValidationServiceProvider::class, diff --git a/src/Providers/RemoteApiServiceProvider.php b/src/Providers/RemoteApiServiceProvider.php new file mode 100644 index 000000000..6a41c1674 --- /dev/null +++ b/src/Providers/RemoteApiServiceProvider.php @@ -0,0 +1,51 @@ +app->singleton(RemoteApiConnectorResolver::class); + $this->app->singleton(RemoteApiRateLimiter::class); + $this->app->singleton(RemoteApiConnectorFactory::class); + $this->app->singleton(RemoteApiSynchronizer::class); + } + + /** + * Bootstrap RemoteApi feature services. + */ + public function boot(): void + { + $this->app['config']->set('logging.channels.modularous-remote-api', [ + 'driver' => 'daily', + 'path' => storage_path('logs/modularous-remote-api.log'), + 'level' => env('MODULAROUS_REMOTE_API_LOG_LEVEL', 'info'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ]); + } + + /** + * @return list + */ + public function provides(): array + { + return [ + RemoteApiConnectorResolver::class, + RemoteApiRateLimiter::class, + RemoteApiConnectorFactory::class, + RemoteApiSynchronizer::class, + ]; + } +} diff --git a/src/Services/ArtisanRunner/ArtisanRunner.php b/src/Services/ArtisanRunner/ArtisanRunner.php new file mode 100644 index 000000000..47ea1f82b --- /dev/null +++ b/src/Services/ArtisanRunner/ArtisanRunner.php @@ -0,0 +1,162 @@ + $roles */ + $roles = array_values(array_filter( + (array) modularousConfig('artisan_runner.allowed_roles', ['superadmin']) + )); + + if ($roles === []) { + return false; + } + + if ($this->catalog->isSuperadmin($user) && in_array('superadmin', $roles, true)) { + return true; + } + + if (is_callable([$user, 'hasAnyRole'])) { + return (bool) $user->hasAnyRole($roles); + } + + if (is_callable([$user, 'hasRole'])) { + foreach ($roles as $role) { + if ($user->hasRole($role)) { + return true; + } + } + } + + return false; + } + + public function catalogForUser(Authenticatable $user): array + { + $this->assertPanelAccess($user); + + return $this->catalog->forUser($user); + } + + public function definitionForUser(Authenticatable $user, string $command): array + { + $this->assertPanelAccess($user); + $this->assertCommandAllowed($user, $command); + + $resolved = $this->catalog->findForUser($user, $command); + if ($resolved === null) { + throw new CommandNotAllowedException("Command [{$command}] is not available."); + } + + return $this->inspector->inspect($resolved); + } + + public function run( + Authenticatable $user, + string $command, + array $arguments, + array $options, + string $runId, + callable $emit, + ): int { + $this->assertPanelAccess($user); + $this->assertCommandAllowed($user, $command); + + $emit('started', ['runId' => $runId, 'command' => $command]); + + try { + $exitCode = $this->executor->execute( + $command, + $arguments, + $options, + $runId, + $emit, + ); + } catch (ArtisanRunnerException $e) { + $emit('error', ['message' => $e->getMessage()]); + $emit('done', ['runId' => $runId, 'exitCode' => 1, 'error' => $e->getMessage()]); + + return 1; + } catch (\Throwable $e) { + $emit('error', ['message' => $e->getMessage()]); + $emit('done', ['runId' => $runId, 'exitCode' => 1, 'error' => $e->getMessage()]); + + return 1; + } + + $emit('done', ['runId' => $runId, 'exitCode' => $exitCode]); + + return $exitCode; + } + + public function answerPrompt(string $runId, string $promptId, mixed $answer): void + { + $this->questionBroker->publishAnswer($runId, $promptId, $answer); + } + + public function assertCommandAllowed(Authenticatable $user, string $command): void + { + if (! $this->catalog->isAllowed($user, $command)) { + throw new CommandNotAllowedException("Command [{$command}] is not allowed for this user."); + } + } + + /** + * @return array{commands: string, definition: string, run: string, answer: string} + */ + public function panelEndpoints(): array + { + $prefix = Modularous::getAdminRouteNamePrefix() . '.'; + + return [ + 'commands' => route($prefix . 'artisan-runner.commands'), + 'definition' => route($prefix . 'artisan-runner.commands.show', ['name' => '__NAME__']), + 'run' => route($prefix . 'artisan-runner.runs'), + 'answer' => route($prefix . 'artisan-runner.runs.answer', ['runId' => '__RUN_ID__']), + ]; + } + + /** + * @return array{commands: string, definition: string, run: string, answer: string} + */ + public function emptyPanelEndpoints(): array + { + return [ + 'commands' => '', + 'definition' => '', + 'run' => '', + 'answer' => '', + ]; + } + + private function assertPanelAccess(Authenticatable $user): void + { + if (! $this->userCanAccess($user)) { + throw new ArtisanRunnerException('Artisan Runner is disabled or you are not allowed to access it.'); + } + } +} diff --git a/src/Services/ArtisanRunner/CommandCatalog.php b/src/Services/ArtisanRunner/CommandCatalog.php new file mode 100644 index 000000000..8d11ca358 --- /dev/null +++ b/src/Services/ArtisanRunner/CommandCatalog.php @@ -0,0 +1,106 @@ +allCommands() as $name => $command) { + if (! $this->isAllowed($user, $name)) { + continue; + } + + $items[] = [ + 'name' => $name, + 'description' => (string) $command->getDescription(), + ]; + } + + usort($items, static fn (array $a, array $b): int => strcmp($a['name'], $b['name'])); + + return $items; + } + + public function findForUser(Authenticatable $user, string $name): ?Command + { + if (! $this->isAllowed($user, $name)) { + return null; + } + + $commands = $this->allCommands(); + + return $commands[$name] ?? null; + } + + public function isAllowed(Authenticatable $user, string $name): bool + { + if (! array_key_exists($name, $this->allCommands())) { + return false; + } + + if ($this->isSuperadmin($user)) { + return true; + } + + /** @var list $allowlist */ + $allowlist = array_values(array_filter( + (array) modularousConfig('artisan_runner.allowlist', []) + )); + + if ($allowlist === []) { + return false; + } + + return $this->allowlistMatcher->matches($name, $allowlist); + } + + public function isSuperadmin(Authenticatable $user): bool + { + if (isset($user->is_superadmin) && $user->is_superadmin) { + return true; + } + + if (is_callable([$user, 'hasRole'])) { + return (bool) $user->hasRole('superadmin'); + } + + return false; + } + + /** + * @return array + */ + private function allCommands(): array + { + $commands = []; + + foreach (Artisan::all() as $name => $command) { + if (! $command instanceof Command) { + continue; + } + + if ($command->isHidden()) { + continue; + } + + $commands[$name] = $command; + } + + return $commands; + } +} diff --git a/src/Services/ArtisanRunner/CommandDefinitionInspector.php b/src/Services/ArtisanRunner/CommandDefinitionInspector.php new file mode 100644 index 000000000..dffe3ccc5 --- /dev/null +++ b/src/Services/ArtisanRunner/CommandDefinitionInspector.php @@ -0,0 +1,104 @@ +>, + * options: list> + * } + */ + public function inspect(Command $command): array + { + $definition = $command->getDefinition(); + + $arguments = []; + foreach ($definition->getArguments() as $argument) { + if ($argument->getName() === 'command') { + continue; + } + + $arguments[] = $this->mapArgument($argument); + } + + $options = []; + foreach ($definition->getOptions() as $option) { + if (in_array($option->getName(), ['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'env'], true)) { + continue; + } + + $options[] = $this->mapOption($option); + } + + return [ + 'name' => $command->getName() ?? '', + 'description' => (string) $command->getDescription(), + 'arguments' => $arguments, + 'options' => $options, + ]; + } + + /** + * @return array + */ + private function mapArgument(InputArgument $argument): array + { + $isArray = $argument->isArray(); + $required = $argument->isRequired(); + + return [ + 'name' => $argument->getName(), + 'description' => (string) $argument->getDescription(), + 'required' => $required, + 'is_array' => $isArray, + 'default' => $argument->getDefault(), + 'field' => 'textarea', + 'rules' => $required ? ['required'] : [], + ]; + } + + /** + * @return array + */ + private function mapOption(InputOption $option): array + { + $isArray = $option->isArray(); + $required = $option->isValueRequired(); + + if (! $option->acceptValue() && ! $isArray) { + return [ + 'name' => $option->getName(), + 'shortcut' => $option->getShortcut(), + 'description' => (string) $option->getDescription(), + 'required' => false, + 'is_array' => false, + 'accept_value' => false, + 'default' => false, + 'field' => 'switch', + 'rules' => [], + ]; + } + + return [ + 'name' => $option->getName(), + 'shortcut' => $option->getShortcut(), + 'description' => (string) $option->getDescription(), + 'required' => $required, + 'is_array' => $isArray, + 'accept_value' => true, + 'default' => $option->getDefault(), + 'field' => 'textarea', + 'rules' => $required ? ['required'] : [], + ]; + } +} diff --git a/src/Services/ArtisanRunner/CommandExecutor.php b/src/Services/ArtisanRunner/CommandExecutor.php new file mode 100644 index 000000000..20d53e154 --- /dev/null +++ b/src/Services/ArtisanRunner/CommandExecutor.php @@ -0,0 +1,411 @@ + true, + 'in_process' => false, + default => $this->allowlistMatcher->matches( + $commandName, + array_values(array_filter( + (array) modularousConfig('artisan_runner.subprocess_commands', []) + )), + ), + }; + } + + /** + * @param array $arguments + * @param array $options + * @param callable(string $event, array $payload): void $emit + */ + public function execute( + string $commandName, + array $arguments, + array $options, + string $runId, + callable $emit, + ?int $timeout = null, + ?int $maxOutputBytes = null, + ?int $promptTimeout = null, + ): int { + $timeout ??= (int) modularousConfig('artisan_runner.timeout', 120); + $maxOutputBytes ??= (int) modularousConfig('artisan_runner.max_output_bytes', 1_048_576); + $promptTimeout ??= (int) modularousConfig('artisan_runner.prompt_timeout', 60); + + if ($this->shouldExecuteViaSubprocess($commandName)) { + return $this->executeViaSubprocess( + $commandName, + $arguments, + $options, + $emit, + $timeout, + $maxOutputBytes, + ); + } + + return $this->executeInProcess( + $commandName, + $arguments, + $options, + $runId, + $emit, + $timeout, + $maxOutputBytes, + $promptTimeout, + ); + } + + /** + * @param array $arguments + * @param array $options + * @param callable(string $event, array $payload): void $emit + */ + private function executeInProcess( + string $commandName, + array $arguments, + array $options, + string $runId, + callable $emit, + int $timeout, + int $maxOutputBytes, + int $promptTimeout, + ): int { + $previousLimit = ini_get('max_execution_time'); + if ($timeout > 0) { + set_time_limit($timeout); + } + + $broker = new InteractiveQuestionBroker($promptTimeout); + $streamed = new StreamedConsoleOutput($emit, $maxOutputBytes); + $helper = new BridgedQuestionHelper($runId, $broker, $emit); + + $parameters = $this->buildParameters($commandName, $arguments, $options); + $input = new ArrayInput($parameters); + $input->setInteractive(true); + + // $this->confirm()/ask()/choice() use SymfonyStyle::$questionHelper, not HelperSet. + $output = new OutputStyle($input, $streamed); + $this->installQuestionHelper($helper, $output); + + try { + return $this->kernel->handle($input, $output); + } finally { + if ($previousLimit !== false) { + set_time_limit((int) $previousLimit); + } + } + } + + /** + * Spawn `php artisan {command}` so PHP_SAPI is cli — true route/config cache parity. + * + * @param array $arguments + * @param array $options + * @param callable(string $event, array $payload): void $emit + */ + private function executeViaSubprocess( + string $commandName, + array $arguments, + array $options, + callable $emit, + int $timeout, + int $maxOutputBytes, + ): int { + $parameters = $this->buildParameters($commandName, $arguments, $options); + $commandLine = $this->buildSubprocessCommandLine($parameters); + + $process = new Process( + $commandLine, + base_path(), + ['APP_RUNNING_IN_CONSOLE' => '1'], + null, + $timeout > 0 ? (float) $timeout : null, + ); + + $bytesWritten = 0; + + $process->run(function (string $type, string $buffer) use ($emit, $maxOutputBytes, &$bytesWritten): void { + $length = strlen($buffer); + if ($bytesWritten + $length > $maxOutputBytes) { + $remaining = max(0, $maxOutputBytes - $bytesWritten); + if ($remaining > 0) { + $emit('output', ['chunk' => substr($buffer, 0, $remaining)]); + $bytesWritten += $remaining; + } + + throw new ArtisanRunnerException('Command output exceeded max_output_bytes limit.'); + } + + $bytesWritten += $length; + $emit('output', ['chunk' => $buffer]); + }); + + return $process->getExitCode() ?? 1; + } + + /** + * @param array $parameters ArrayInput-style parameters from buildParameters() + * @return list + */ + public function buildSubprocessCommandLine(array $parameters): array + { + $php = (new PhpExecutableFinder)->find(false); + if ($php === false || $php === '') { + throw new ArtisanRunnerException('Unable to locate PHP CLI binary for subprocess execution.'); + } + + $artisan = base_path('artisan'); + if (! is_file($artisan)) { + throw new ArtisanRunnerException('Unable to locate artisan for subprocess execution.'); + } + + $commandName = (string) ($parameters['command'] ?? ''); + if ($commandName === '') { + throw new ArtisanRunnerException('Missing command name for subprocess execution.'); + } + + $line = [$php, $artisan, $commandName]; + + foreach ($parameters as $key => $value) { + if ($key === 'command') { + continue; + } + + if (is_string($key) && str_starts_with($key, '--')) { + if ($value === true) { + $line[] = $key; + + continue; + } + + if ($value === false || $value === null) { + continue; + } + + if (is_array($value)) { + foreach ($value as $item) { + $line[] = $key.'='.(string) $item; + } + + continue; + } + + $line[] = $key.'='.(string) $value; + + continue; + } + + if (is_array($value)) { + foreach ($value as $item) { + $line[] = (string) $item; + } + + continue; + } + + $line[] = (string) $value; + } + + // Subprocess cannot use BridgedQuestionHelper; avoid hanging on prompts. + $line[] = '--no-interaction'; + + return $line; + } + + /** + * @param array $arguments + * @param array $options + * @return array + */ + public function buildParameters(string $commandName, array $arguments, array $options): array + { + $command = null; + foreach (\Illuminate\Support\Facades\Artisan::all() as $name => $cmd) { + if ($name === $commandName) { + $command = $cmd; + break; + } + } + + if ($command === null) { + throw new ArtisanRunnerException("Unknown command [{$commandName}]."); + } + + $definition = $command->getDefinition(); + $parameters = ['command' => $commandName]; + + foreach ($definition->getArguments() as $argument) { + $name = $argument->getName(); + if ($name === 'command') { + continue; + } + + if (! array_key_exists($name, $arguments)) { + if ($argument->isRequired()) { + throw new ArtisanRunnerException("Missing required argument [{$name}]."); + } + + continue; + } + + $value = $this->normalizeArgumentValue($argument, $arguments[$name]); + if ($value === null || $value === '') { + if ($argument->isRequired()) { + throw new ArtisanRunnerException("Missing required argument [{$name}]."); + } + + continue; + } + + $parameters[$name] = $value; + } + + foreach ($definition->getOptions() as $option) { + $name = $option->getName(); + if (in_array($name, ['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'env'], true)) { + continue; + } + + if (! array_key_exists($name, $options)) { + continue; + } + + $raw = $options[$name]; + $key = '--'.$name; + + if (! $option->acceptValue()) { + if (filter_var($raw, FILTER_VALIDATE_BOOLEAN)) { + $parameters[$key] = true; + } + + continue; + } + + $value = $this->normalizeOptionValue($option, $raw); + if ($value === null || $value === '') { + if ($option->isValueRequired()) { + throw new ArtisanRunnerException("Missing required option [--{$name}]."); + } + + continue; + } + + $parameters[$key] = $value; + } + + return $parameters; + } + + /** + * Wire BridgedQuestionHelper into both SymfonyStyle (confirm/ask/choice) and HelperSet + * ($this->getHelper('question')). + */ + private function installQuestionHelper(BridgedQuestionHelper $helper, OutputStyle $output): void + { + $property = new ReflectionProperty(SymfonyStyle::class, 'questionHelper'); + $property->setAccessible(true); + $property->setValue($output, $helper); + + $artisan = $this->resolveArtisanApplication(); + $helperSet = $artisan->getHelperSet(); + if (! $helperSet instanceof HelperSet) { + $helperSet = new HelperSet; + $artisan->setHelperSet($helperSet); + } + + $helperSet->set($helper); + } + + private function resolveArtisanApplication(): \Illuminate\Console\Application + { + $method = new ReflectionMethod($this->kernel, 'getArtisan'); + $method->setAccessible(true); + + /** @var \Illuminate\Console\Application $artisan */ + $artisan = $method->invoke($this->kernel); + + return $artisan; + } + + private function normalizeArgumentValue(InputArgument $argument, mixed $raw): mixed + { + if ($argument->isArray()) { + return $this->splitList($raw); + } + + if (is_array($raw)) { + return implode(' ', array_map('strval', $raw)); + } + + return is_scalar($raw) || $raw === null ? $raw : (string) $raw; + } + + private function normalizeOptionValue(InputOption $option, mixed $raw): mixed + { + if ($option->isArray()) { + return $this->splitList($raw); + } + + if (is_array($raw)) { + return implode(',', array_map('strval', $raw)); + } + + return is_scalar($raw) || $raw === null ? $raw : (string) $raw; + } + + /** + * @return list + */ + private function splitList(mixed $raw): array + { + if (is_array($raw)) { + return array_values(array_filter(array_map( + static fn ($item) => trim((string) $item), + $raw + ), static fn (string $item) => $item !== '')); + } + + $text = trim((string) $raw); + if ($text === '') { + return []; + } + + $parts = preg_split('/[\r\n,]+/', $text) ?: []; + + return array_values(array_filter(array_map('trim', $parts), static fn (string $item) => $item !== '')); + } +} diff --git a/src/Services/ArtisanRunner/Contracts/ArtisanRunnerInterface.php b/src/Services/ArtisanRunner/Contracts/ArtisanRunnerInterface.php new file mode 100644 index 000000000..58844a373 --- /dev/null +++ b/src/Services/ArtisanRunner/Contracts/ArtisanRunnerInterface.php @@ -0,0 +1,57 @@ + + */ + public function catalogForUser(Authenticatable $user): array; + + /** + * @return array{ + * name: string, + * description: string, + * arguments: list>, + * options: list> + * } + */ + public function definitionForUser(Authenticatable $user, string $command): array; + + /** + * @param array $arguments + * @param array $options + * @param callable(string $event, array $payload): void $emit + */ + public function run( + Authenticatable $user, + string $command, + array $arguments, + array $options, + string $runId, + callable $emit, + ): int; + + public function answerPrompt(string $runId, string $promptId, mixed $answer): void; + + public function userCanAccess(Authenticatable $user): bool; + + public function assertCommandAllowed(Authenticatable $user, string $command): void; + + /** + * Session-authenticated panel API URLs (placeholders __NAME__ / __RUN_ID__). + * + * @return array{commands: string, definition: string, run: string, answer: string} + */ + public function panelEndpoints(): array; + + /** + * @return array{commands: string, definition: string, run: string, answer: string} + */ + public function emptyPanelEndpoints(): array; +} diff --git a/src/Services/ArtisanRunner/Contracts/CommandCatalogInterface.php b/src/Services/ArtisanRunner/Contracts/CommandCatalogInterface.php new file mode 100644 index 000000000..8c5970d68 --- /dev/null +++ b/src/Services/ArtisanRunner/Contracts/CommandCatalogInterface.php @@ -0,0 +1,22 @@ + + */ + public function forUser(Authenticatable $user): array; + + public function findForUser(Authenticatable $user, string $name): ?Command; + + public function isAllowed(Authenticatable $user, string $name): bool; + + public function isSuperadmin(Authenticatable $user): bool; +} diff --git a/src/Services/ArtisanRunner/Exceptions/ArtisanRunnerException.php b/src/Services/ArtisanRunner/Exceptions/ArtisanRunnerException.php new file mode 100644 index 000000000..809ab9158 --- /dev/null +++ b/src/Services/ArtisanRunner/Exceptions/ArtisanRunnerException.php @@ -0,0 +1,11 @@ +confirm() / ask() / choice()). + * + * @phpstan-type EmitCallable callable(string $event, array $payload): void + */ +final class BridgedQuestionHelper extends SymfonyQuestionHelper +{ + /** @var EmitCallable */ + private $emit; + + /** + * @param EmitCallable $emit + */ + public function __construct( + private readonly string $runId, + private readonly InteractiveQuestionBroker $broker, + callable $emit, + ) { + $this->emit = $emit; + } + + public function ask(InputInterface $input, OutputInterface $output, Question $question): mixed + { + $promptId = (string) Str::uuid(); + $type = $this->resolveType($question); + + $payload = [ + 'id' => $promptId, + 'type' => $type, + 'question' => $question->getQuestion(), + 'default' => $question->getDefault(), + ]; + + if ($question instanceof ChoiceQuestion) { + $payload['choices'] = array_values($question->getChoices()); + $payload['multiselect'] = $question->isMultiselect(); + } + + ($this->emit)('prompt', $payload); + + $answer = $this->broker->waitForAnswer($this->runId, $promptId); + + if ($question instanceof ConfirmationQuestion) { + return filter_var($answer, FILTER_VALIDATE_BOOLEAN); + } + + return $answer; + } + + private function resolveType(Question $question): string + { + if ($question instanceof ConfirmationQuestion) { + return 'confirm'; + } + + if ($question instanceof ChoiceQuestion) { + return 'choice'; + } + + return 'ask'; + } +} diff --git a/src/Services/ArtisanRunner/Streaming/InteractiveQuestionBroker.php b/src/Services/ArtisanRunner/Streaming/InteractiveQuestionBroker.php new file mode 100644 index 000000000..5be297db0 --- /dev/null +++ b/src/Services/ArtisanRunner/Streaming/InteractiveQuestionBroker.php @@ -0,0 +1,57 @@ +answerKey($runId, $promptId), + ['answer' => $answer], + now()->addSeconds($this->promptTimeoutSeconds + 30), + ); + } + + /** + * Block until an answer is published or timeout elapses. + * + * @throws ArtisanRunnerException + */ + public function waitForAnswer(string $runId, string $promptId): mixed + { + $key = $this->answerKey($runId, $promptId); + $deadline = microtime(true) + $this->promptTimeoutSeconds; + + while (microtime(true) < $deadline) { + $payload = Cache::pull($key); + + if (is_array($payload) && array_key_exists('answer', $payload)) { + return $payload['answer']; + } + + usleep(150_000); + } + + throw new ArtisanRunnerException("Timed out waiting for interactive prompt answer ({$promptId})."); + } + + public function forget(string $runId, string $promptId): void + { + Cache::forget($this->answerKey($runId, $promptId)); + } +} diff --git a/src/Services/ArtisanRunner/Streaming/StreamedConsoleOutput.php b/src/Services/ArtisanRunner/Streaming/StreamedConsoleOutput.php new file mode 100644 index 000000000..690856a40 --- /dev/null +++ b/src/Services/ArtisanRunner/Streaming/StreamedConsoleOutput.php @@ -0,0 +1,59 @@ + $payload): void + */ +final class StreamedConsoleOutput extends Output +{ + /** @var EmitCallable */ + private $emit; + + private int $bytesWritten = 0; + + /** + * @param EmitCallable $emit + */ + public function __construct( + callable $emit, + private readonly int $maxOutputBytes = 1_048_576, + int $verbosity = self::VERBOSITY_NORMAL, + bool $decorated = false, + ) { + parent::__construct($verbosity, $decorated); + $this->emit = $emit; + } + + protected function doWrite(string $message, bool $newline): void + { + $chunk = $newline ? $message . PHP_EOL : $message; + $length = strlen($chunk); + + if ($this->bytesWritten + $length > $this->maxOutputBytes) { + $remaining = max(0, $this->maxOutputBytes - $this->bytesWritten); + if ($remaining > 0) { + $chunk = substr($chunk, 0, $remaining); + ($this->emit)('output', ['chunk' => $chunk]); + $this->bytesWritten += $remaining; + } + + throw new ArtisanRunnerException('Command output exceeded max_output_bytes limit.'); + } + + $this->bytesWritten += $length; + ($this->emit)('output', ['chunk' => $chunk]); + } + + public function bytesWritten(): int + { + return $this->bytesWritten; + } +} diff --git a/src/Services/ArtisanRunner/Support/AllowlistMatcher.php b/src/Services/ArtisanRunner/Support/AllowlistMatcher.php new file mode 100644 index 000000000..b2c0bdea5 --- /dev/null +++ b/src/Services/ArtisanRunner/Support/AllowlistMatcher.php @@ -0,0 +1,36 @@ + $patterns + */ + public function matches(string $command, array $patterns): bool + { + foreach ($patterns as $pattern) { + if ($pattern === '') { + continue; + } + + if ($pattern === $command) { + return true; + } + + if (! str_contains($pattern, '*')) { + continue; + } + + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + + if (preg_match($regex, $command) === 1) { + return true; + } + } + + return false; + } +} diff --git a/src/Services/Settings/AbstractSingularSettingsService.php b/src/Services/Settings/AbstractSingularSettingsService.php index 0881669a0..41c954607 100644 --- a/src/Services/Settings/AbstractSingularSettingsService.php +++ b/src/Services/Settings/AbstractSingularSettingsService.php @@ -199,6 +199,10 @@ protected function snapshotFromModel(object $model): array $snapshot['smtp']['password'] = $this->decryptSecret($snapshot['smtp']['password']); } + if (isset($snapshot['down_presets']['secret']) && is_string($snapshot['down_presets']['secret']) && $snapshot['down_presets']['secret'] !== '') { + $snapshot['down_presets']['secret'] = $this->decryptSecret($snapshot['down_presets']['secret']); + } + return $snapshot; } diff --git a/src/Services/SystemConsole/SystemConsoleConfig.php b/src/Services/SystemConsole/SystemConsoleConfig.php new file mode 100644 index 000000000..20b1adf16 --- /dev/null +++ b/src/Services/SystemConsole/SystemConsoleConfig.php @@ -0,0 +1,204 @@ + */ + private const DOWN_OPTION_KEYS = ['render', 'retry', 'refresh', 'secret']; + + /** + * @return list}> + */ + public static function downPresetsForUi(): array + { + /** @var array $presets */ + $presets = (array) modularousConfig('system_console.down_presets', []); + $settingsOverrides = self::downPresetOverridesFromSettings(); + $defaultKey = (string) modularousConfig('system_console.default_down_preset', 'default'); + + $items = []; + $seen = []; + foreach ($presets as $key => $preset) { + if (! is_array($preset)) { + continue; + } + + $presetKey = (string) $key; + if ($presetKey === '' || isset($seen[$presetKey])) { + continue; + } + $seen[$presetKey] = true; + + $label = (string) ($preset['label'] ?? $presetKey); + $options = self::normalizeOptions((array) ($preset['options'] ?? [])); + + if ($presetKey === $defaultKey || ($defaultKey === '' && $presetKey === 'default')) { + $options = self::mergeDownOptions($options, $settingsOverrides); + } + + $items[] = [ + 'key' => $presetKey, + 'label' => $label, + 'options' => $options, + ]; + } + + return $items; + } + + public static function defaultDownPresetKey(): string + { + $default = (string) modularousConfig('system_console.default_down_preset', 'default'); + $keys = array_column(self::downPresetsForUi(), 'key'); + + if ($keys === []) { + return ''; + } + + return in_array($default, $keys, true) ? $default : $keys[0]; + } + + /** + * @return list + */ + public static function cacheCommandsForUi(): array + { + /** @var array $commands */ + $commands = (array) modularousConfig('system_console.cache_commands', []); + + $items = []; + $seen = []; + + foreach ($commands as $command) { + if (! is_array($command) || empty($command['command'])) { + continue; + } + + $name = (string) $command['command']; + if (isset($seen[$name])) { + continue; + } + $seen[$name] = true; + + $items[] = [ + 'command' => $name, + 'label' => (string) ($command['label'] ?? $name), + 'confirm' => (bool) ($command['confirm'] ?? false), + 'color' => isset($command['color']) ? (string) $command['color'] : null, + 'variant' => isset($command['variant']) ? (string) $command['variant'] : 'tonal', + 'icon' => isset($command['icon']) ? (string) $command['icon'] : null, + ]; + } + + return $items; + } + + public static function isInMaintenance(): bool + { + try { + return (bool) app()->isDownForMaintenance(); + } catch (\Throwable) { + return is_file(storage_path('framework/down')); + } + } + + /** + * Drop null/empty option values so ArtisanRunner omits unused flags. + * + * @param array $options + * @return array + */ + public static function normalizeOptions(array $options): array + { + $normalized = []; + + foreach ($options as $name => $value) { + if ($value === null || $value === '') { + continue; + } + + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $normalized[(string) $name] = $value; + } + } + + return $normalized; + } + + /** + * Non-empty SystemSettings overrides for artisan down options. + * + * @return array + */ + public static function downPresetOverridesFromSettings(): array + { + if (! self::systemSettingsAvailable()) { + return []; + } + + $overrides = []; + + try { + foreach (self::DOWN_OPTION_KEYS as $key) { + $value = SystemSettings::get("down_presets.{$key}"); + + if ($value === null || $value === '') { + continue; + } + + if (in_array($key, ['retry', 'refresh'], true) && is_numeric($value)) { + $overrides[$key] = (int) $value; + + continue; + } + + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $overrides[$key] = $value; + } + } + } catch (\Throwable) { + return []; + } + + return $overrides; + } + + /** + * @param array $base + * @param array $overrides + * @return array + */ + private static function mergeDownOptions(array $base, array $overrides): array + { + if ($overrides === []) { + return $base; + } + + return self::normalizeOptions(array_merge($base, $overrides)); + } + + private static function systemSettingsAvailable(): bool + { + try { + return class_exists(SystemSettings::class) + && app()->bound('system.settings'); + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/View/Component.php b/src/View/Component.php index 75875cbe5..1cd192f88 100755 --- a/src/View/Component.php +++ b/src/View/Component.php @@ -247,6 +247,8 @@ public function mergeAttributes($attributes) $attributes )); + $this->attributesHydrated = true; + return $this; } diff --git a/src/View/ModularousWidget.php b/src/View/ModularousWidget.php index 695bbe8f5..d9e149d60 100644 --- a/src/View/ModularousWidget.php +++ b/src/View/ModularousWidget.php @@ -82,6 +82,7 @@ public function __construct() if (count($this->attributes) > 0) { $this->attributes = $this->hydrateAttributes($this->attributes); + $this->attributesHydrated = true; } } @@ -165,12 +166,21 @@ public function render() $component = new Component; + // Merge raw widget-config attributes with instance attributes. + // Do not hydrate the config side separately then merge: that doubles numeric + // list props via array_merge_recursive_preserve (e.g. SystemConsole cacheCommands). + $attributes = array_merge_recursive_preserve( + $this->widgetConfigUsable ? ($componentConfig['attributes'] ?? []) : [], + $this->attributes ?? [] + ); + + if (! $this->attributesHydrated) { + $attributes = $this->hydrateAttributes($attributes); + } + $component = $component->makeComponent( tag: $this->tag, - attributes: array_merge_recursive_preserve( - $this->widgetConfigUsable ? $this->hydrateAttributes($componentConfig['attributes'] ?? []) : [], - $this->attributes ?? [] - ), + attributes: $attributes, elements: $this->elements, slots: array_merge( $this->widgetConfigUsable ? $componentConfig['slots'] ?? [] : [], diff --git a/src/View/Widgets/ArtisanRunnerWidget.php b/src/View/Widgets/ArtisanRunnerWidget.php new file mode 100644 index 000000000..deea8a449 --- /dev/null +++ b/src/View/Widgets/ArtisanRunnerWidget.php @@ -0,0 +1,46 @@ + 12, + 'lg' => 6, + ]; + + public $attributes = [ + 'class' => 'h-100', + 'title' => 'Artisan Runner', + 'elevation' => 2, + 'subtitle' => 'Select a command to configure and run.', + ]; + + public function hydrateAttributes($attributes) + { + $attributes = parent::hydrateAttributes($attributes); + + /** @var ArtisanRunnerInterface $runner */ + $runner = app(ArtisanRunnerInterface::class); + $user = auth()->user(); + + $canAccess = $user !== null && $runner->userCanAccess($user); + + $attributes['runnerDisabled'] = ! $canAccess; + $attributes['endpoints'] = $canAccess + ? $runner->panelEndpoints() + : $runner->emptyPanelEndpoints(); + $attributes['isSuperadmin'] = (bool) ($user->is_superadmin ?? false); + + return $attributes; + } +} diff --git a/src/View/Widgets/SystemConsoleWidget.php b/src/View/Widgets/SystemConsoleWidget.php new file mode 100644 index 000000000..0f74a6503 --- /dev/null +++ b/src/View/Widgets/SystemConsoleWidget.php @@ -0,0 +1,51 @@ + 12, + 'lg' => 6, + ]; + + public $attributes = [ + 'class' => 'h-100', + 'title' => 'System Console', + 'elevation' => 2, + 'subtitle' => 'Maintenance mode and cache / optimize commands.', + ]; + + public function hydrateAttributes($attributes) + { + $attributes = parent::hydrateAttributes($attributes); + + /** @var ArtisanRunnerInterface $runner */ + $runner = app(ArtisanRunnerInterface::class); + $user = auth()->user(); + + $canAccess = $user !== null && $runner->userCanAccess($user); + + $attributes['runnerDisabled'] = ! $canAccess; + $attributes['endpoints'] = $canAccess + ? $runner->panelEndpoints() + : $runner->emptyPanelEndpoints(); + $attributes['isSuperadmin'] = (bool) ($user->is_superadmin ?? false); + $attributes['maintenanceMode'] = SystemConsoleConfig::isInMaintenance(); + $attributes['downPresets'] = SystemConsoleConfig::downPresetsForUi(); + $attributes['defaultDownPreset'] = SystemConsoleConfig::defaultDownPresetKey(); + $attributes['cacheCommands'] = SystemConsoleConfig::cacheCommandsForUi(); + + return $attributes; + } +} diff --git a/tests/Services/ArtisanRunner/AllowlistMatcherTest.php b/tests/Services/ArtisanRunner/AllowlistMatcherTest.php new file mode 100644 index 000000000..b3a2c9013 --- /dev/null +++ b/tests/Services/ArtisanRunner/AllowlistMatcherTest.php @@ -0,0 +1,50 @@ +matcher = new AllowlistMatcher; + } + + /** @test */ + public function it_matches_exact_command_names(): void + { + $this->assertTrue($this->matcher->matches('inspire', ['inspire', 'list'])); + $this->assertFalse($this->matcher->matches('cache:clear', ['inspire'])); + } + + /** @test */ + public function it_matches_glob_patterns(): void + { + $this->assertTrue($this->matcher->matches( + 'modularous:cache:clear', + ['modularous:cache:*'] + )); + $this->assertTrue($this->matcher->matches( + 'modularous:cache:warm-presentation', + ['modularous:cache:*'] + )); + $this->assertFalse($this->matcher->matches( + 'modularous:sync-remote-api', + ['modularous:cache:*'] + )); + } + + /** @test */ + public function it_rejects_empty_patterns(): void + { + $this->assertFalse($this->matcher->matches('inspire', [])); + $this->assertFalse($this->matcher->matches('inspire', ['', ' '])); + } +} diff --git a/tests/Services/ArtisanRunner/ArtisanRunnerServiceTest.php b/tests/Services/ArtisanRunner/ArtisanRunnerServiceTest.php new file mode 100644 index 000000000..4fcaa48c5 --- /dev/null +++ b/tests/Services/ArtisanRunner/ArtisanRunnerServiceTest.php @@ -0,0 +1,306 @@ +registerDemoCommand(); + + config([ + 'modularous.artisan_runner.enabled' => true, + 'modularous.artisan_runner.allowlist' => ['artisan-runner:demo'], + 'modularous.artisan_runner.allowed_roles' => ['superadmin', 'admin'], + 'modularous.artisan_runner.timeout' => 30, + 'modularous.artisan_runner.max_output_bytes' => 1_048_576, + 'modularous.artisan_runner.prompt_timeout' => 5, + ]); + } + + /** @test */ + public function it_denies_access_when_disabled(): void + { + config(['modularous.artisan_runner.enabled' => false]); + + $runner = $this->app->make(ArtisanRunnerInterface::class); + $user = $this->fakeUser(true); + + $this->assertFalse($runner->userCanAccess($user)); + $this->expectException(ArtisanRunnerException::class); + $runner->catalogForUser($user); + } + + /** @test */ + public function non_superadmin_cannot_run_commands_outside_allowlist(): void + { + $runner = $this->app->make(ArtisanRunnerInterface::class); + $user = $this->fakeUser(false); + + $this->expectException(CommandNotAllowedException::class); + $runner->assertCommandAllowed($user, 'artisan-runner:other'); + } + + /** @test */ + public function it_streams_demo_output_for_allowlisted_user(): void + { + $runner = $this->app->make(ArtisanRunnerInterface::class); + $user = $this->fakeUser(false); + + $events = []; + $exitCode = $runner->run( + $user, + 'artisan-runner:demo', + [], + [], + 'test-run-demo', + function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + } + ); + + $this->assertSame(0, $exitCode); + $eventNames = array_column($events, 'event'); + $this->assertContains('started', $eventNames); + $this->assertContains('done', $eventNames); + $this->assertContains('output', $eventNames); + + $chunks = ''; + foreach ($events as $event) { + if ($event['event'] === 'output') { + $chunks .= $event['payload']['chunk'] ?? ''; + } + } + $this->assertStringContainsString('demo-ok', $chunks); + } + + /** @test */ + public function it_emits_prompt_and_accepts_confirm_answer_without_stdin(): void + { + $this->registerConfirmCommand(); + + config([ + 'modularous.artisan_runner.allowlist' => [ + 'artisan-runner:demo', + 'artisan-runner:confirm', + ], + ]); + + $runner = $this->app->make(ArtisanRunnerInterface::class); + $user = $this->fakeUser(false); + $runId = 'test-run-confirm'; + + $events = []; + $exitCode = $runner->run( + $user, + 'artisan-runner:confirm', + [], + [], + $runId, + function (string $event, array $payload) use (&$events, $runner, $runId): void { + $events[] = ['event' => $event, 'payload' => $payload]; + + if ($event === 'prompt') { + $this->assertSame('confirm', $payload['type'] ?? null); + $this->assertSame('Delete duplicate media records?', $payload['question'] ?? null); + $runner->answerPrompt($runId, (string) $payload['id'], false); + } + } + ); + + $this->assertSame(0, $exitCode); + + $eventNames = array_column($events, 'event'); + $this->assertContains('prompt', $eventNames); + $this->assertNotContains('error', $eventNames); + + $chunks = ''; + foreach ($events as $event) { + if ($event['event'] === 'output') { + $chunks .= $event['payload']['chunk'] ?? ''; + } + } + $this->assertStringContainsString('aborted', $chunks); + $this->assertStringNotContainsString('Undefined constant "STDIN"', $chunks); + } + + /** @test */ + public function it_emits_sequential_prompts_and_completes_multi_step_interaction(): void + { + $this->registerMultiPromptCommand(); + + config([ + 'modularous.artisan_runner.allowlist' => [ + 'artisan-runner:demo', + 'artisan-runner:multi-prompt', + ], + ]); + + $runner = $this->app->make(ArtisanRunnerInterface::class); + $user = $this->fakeUser(false); + $runId = 'test-run-multi-prompt'; + + $promptAnswers = [ + 'Continue with cleanup?' => true, + 'Target environment?' => 'staging', + ]; + $promptIds = []; + $events = []; + + $exitCode = $runner->run( + $user, + 'artisan-runner:multi-prompt', + [], + [], + $runId, + function (string $event, array $payload) use (&$events, &$promptIds, $promptAnswers, $runner, $runId): void { + $events[] = ['event' => $event, 'payload' => $payload]; + + if ($event !== 'prompt') { + return; + } + + $question = (string) ($payload['question'] ?? ''); + $this->assertArrayHasKey($question, $promptAnswers); + $promptIds[] = (string) $payload['id']; + $runner->answerPrompt($runId, (string) $payload['id'], $promptAnswers[$question]); + } + ); + + $this->assertSame(0, $exitCode); + $this->assertCount(2, $promptIds); + $this->assertNotSame($promptIds[0], $promptIds[1]); + + $eventNames = array_column($events, 'event'); + $this->assertSame(2, count(array_filter($eventNames, static fn (string $name): bool => $name === 'prompt'))); + $this->assertContains('done', $eventNames); + $this->assertNotContains('error', $eventNames); + + $chunks = ''; + foreach ($events as $event) { + if ($event['event'] === 'output') { + $chunks .= $event['payload']['chunk'] ?? ''; + } + } + $this->assertStringContainsString('multi-ok:staging', $chunks); + $this->assertStringNotContainsString('Undefined constant "STDIN"', $chunks); + } + + private function registerDemoCommand(): void + { + $command = new class extends Command + { + protected $signature = 'artisan-runner:demo {path?} {--force}'; + + protected $description = 'ArtisanRunner demo command'; + + public function handle(): int + { + $this->line('demo-ok'); + + return self::SUCCESS; + } + }; + + $this->app->make(ConsoleKernel::class)->registerCommand($command); + } + + private function registerConfirmCommand(): void + { + $command = new class extends Command + { + protected $signature = 'artisan-runner:confirm'; + + protected $description = 'ArtisanRunner confirm stub'; + + public function handle(): int + { + if (! $this->confirm('Delete duplicate media records?', false)) { + $this->line('aborted'); + + return self::SUCCESS; + } + + $this->line('confirmed'); + + return self::SUCCESS; + } + }; + + $this->app->make(ConsoleKernel::class)->registerCommand($command); + } + + private function registerMultiPromptCommand(): void + { + $command = new class extends Command + { + protected $signature = 'artisan-runner:multi-prompt'; + + protected $description = 'ArtisanRunner multi-prompt stub'; + + public function handle(): int + { + if (! $this->confirm('Continue with cleanup?', false)) { + $this->line('aborted'); + + return self::SUCCESS; + } + + $env = (string) $this->ask('Target environment?', 'production'); + $this->line('multi-ok:'.$env); + + return self::SUCCESS; + } + }; + + $this->app->make(ConsoleKernel::class)->registerCommand($command); + } + + private function fakeUser(bool $isSuperadmin): Authenticatable + { + return new class($isSuperadmin) extends Authenticatable + { + public bool $is_superadmin; + + /** @var list */ + public array $roles = []; + + public function __construct(bool $isSuperadmin) + { + $this->is_superadmin = $isSuperadmin; + $this->roles = $isSuperadmin ? ['superadmin'] : ['admin']; + } + + public function hasRole($roles): bool + { + $roles = (array) $roles; + + return count(array_intersect($roles, $this->roles)) > 0; + } + + public function hasAnyRole(...$roles): bool + { + $flat = []; + foreach ($roles as $role) { + foreach ((array) $role as $item) { + $flat[] = $item; + } + } + + return count(array_intersect($flat, $this->roles)) > 0; + } + }; + } +} diff --git a/tests/Services/ArtisanRunner/CommandCatalogTest.php b/tests/Services/ArtisanRunner/CommandCatalogTest.php new file mode 100644 index 000000000..9b563faf8 --- /dev/null +++ b/tests/Services/ArtisanRunner/CommandCatalogTest.php @@ -0,0 +1,124 @@ +registerDemoCommand(); + + config([ + 'modularous.artisan_runner.enabled' => true, + 'modularous.artisan_runner.allowlist' => ['artisan-runner:demo', 'modularous:cache:*'], + 'modularous.artisan_runner.allowed_roles' => ['superadmin', 'admin'], + ]); + } + + /** @test */ + public function superadmin_sees_all_non_hidden_commands(): void + { + $catalog = new CommandCatalog(new AllowlistMatcher); + $user = $this->fakeUser(isSuperadmin: true); + + $names = array_column($catalog->forUser($user), 'name'); + + $this->assertNotEmpty($names); + $this->assertContains('artisan-runner:demo', $names); + $this->assertTrue($catalog->isAllowed($user, 'artisan-runner:demo')); + + $nonSuperNames = array_column($catalog->forUser($this->fakeUser(false)), 'name'); + $this->assertGreaterThan(count($nonSuperNames), count($names)); + } + + /** @test */ + public function non_superadmin_is_limited_to_allowlist(): void + { + $catalog = new CommandCatalog(new AllowlistMatcher); + $user = $this->fakeUser(isSuperadmin: false); + + $names = array_column($catalog->forUser($user), 'name'); + + $this->assertContains('artisan-runner:demo', $names); + $this->assertTrue($catalog->isAllowed($user, 'artisan-runner:demo')); + $this->assertFalse($catalog->isAllowed($user, 'artisan-runner:other')); + } + + /** @test */ + public function empty_allowlist_yields_empty_catalog_for_non_superadmin(): void + { + config(['modularous.artisan_runner.allowlist' => []]); + + $catalog = new CommandCatalog(new AllowlistMatcher); + $user = $this->fakeUser(isSuperadmin: false); + + $this->assertSame([], $catalog->forUser($user)); + $this->assertFalse($catalog->isAllowed($user, 'artisan-runner:demo')); + } + + private function registerDemoCommand(): void + { + $command = new class extends Command + { + protected $signature = 'artisan-runner:demo {path?} {--force}'; + + protected $description = 'ArtisanRunner demo command'; + + public function handle(): int + { + $this->line('demo-ok'); + + return self::SUCCESS; + } + }; + + $this->app->make(ConsoleKernel::class)->registerCommand($command); + } + + private function fakeUser(bool $isSuperadmin): Authenticatable + { + return new class($isSuperadmin) extends Authenticatable + { + public bool $is_superadmin; + + /** @var list */ + public array $roles = []; + + public function __construct(bool $isSuperadmin) + { + $this->is_superadmin = $isSuperadmin; + $this->roles = $isSuperadmin ? ['superadmin'] : ['admin']; + } + + public function hasRole($roles): bool + { + $roles = (array) $roles; + + return count(array_intersect($roles, $this->roles)) > 0; + } + + public function hasAnyRole(...$roles): bool + { + $flat = []; + foreach ($roles as $role) { + foreach ((array) $role as $item) { + $flat[] = $item; + } + } + + return count(array_intersect($flat, $this->roles)) > 0; + } + }; + } +} diff --git a/tests/Services/ArtisanRunner/CommandDefinitionInspectorTest.php b/tests/Services/ArtisanRunner/CommandDefinitionInspectorTest.php new file mode 100644 index 000000000..47946a3b7 --- /dev/null +++ b/tests/Services/ArtisanRunner/CommandDefinitionInspectorTest.php @@ -0,0 +1,57 @@ +setName('demo:inspect') + ->setDescription('Demo command') + ->addArgument('path', InputArgument::REQUIRED, 'Target path') + ->addArgument('tags', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Tags') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force') + ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Name'); + } + }; + + $schema = (new CommandDefinitionInspector)->inspect($command); + + $this->assertSame('demo:inspect', $schema['name']); + $this->assertSame('Demo command', $schema['description']); + + $this->assertCount(2, $schema['arguments']); + $this->assertSame('path', $schema['arguments'][0]['name']); + $this->assertTrue($schema['arguments'][0]['required']); + $this->assertSame('textarea', $schema['arguments'][0]['field']); + $this->assertSame(['required'], $schema['arguments'][0]['rules']); + + $this->assertSame('tags', $schema['arguments'][1]['name']); + $this->assertTrue($schema['arguments'][1]['is_array']); + $this->assertFalse($schema['arguments'][1]['required']); + + $force = collect($schema['options'])->firstWhere('name', 'force'); + $this->assertNotNull($force); + $this->assertSame('switch', $force['field']); + $this->assertFalse($force['accept_value']); + + $name = collect($schema['options'])->firstWhere('name', 'name'); + $this->assertNotNull($name); + $this->assertSame('textarea', $name['field']); + $this->assertTrue($name['required']); + $this->assertSame(['required'], $name['rules']); + } +} diff --git a/tests/Services/ArtisanRunner/CommandExecutorTest.php b/tests/Services/ArtisanRunner/CommandExecutorTest.php new file mode 100644 index 000000000..c0dbce655 --- /dev/null +++ b/tests/Services/ArtisanRunner/CommandExecutorTest.php @@ -0,0 +1,92 @@ + 'auto', + 'modularous.artisan_runner.subprocess_commands' => [ + 'route:cache', + 'route:clear', + 'config:cache', + 'optimize', + 'optimize:clear', + ], + ]); + } + + /** @test */ + public function it_selects_subprocess_for_route_cache_in_auto_mode(): void + { + $executor = $this->app->make(CommandExecutor::class); + + $this->assertTrue($executor->shouldExecuteViaSubprocess('route:cache')); + $this->assertTrue($executor->shouldExecuteViaSubprocess('optimize')); + $this->assertFalse($executor->shouldExecuteViaSubprocess('inspire')); + $this->assertFalse($executor->shouldExecuteViaSubprocess('down')); + } + + /** @test */ + public function it_respects_in_process_execution_mode(): void + { + config(['modularous.artisan_runner.execution' => 'in_process']); + + $executor = $this->app->make(CommandExecutor::class); + + $this->assertFalse($executor->shouldExecuteViaSubprocess('route:cache')); + } + + /** @test */ + public function it_respects_subprocess_execution_mode(): void + { + config(['modularous.artisan_runner.execution' => 'subprocess']); + + $executor = $this->app->make(CommandExecutor::class); + + $this->assertTrue($executor->shouldExecuteViaSubprocess('inspire')); + } + + /** @test */ + public function it_builds_subprocess_argv_with_no_interaction(): void + { + $executor = $this->app->make(CommandExecutor::class); + + $line = $executor->buildSubprocessCommandLine([ + 'command' => 'route:cache', + ]); + + $this->assertGreaterThanOrEqual(4, count($line)); + $this->assertSame('route:cache', $line[2]); + $this->assertContains('--no-interaction', $line); + $this->assertTrue(is_file($line[1])); + } + + /** @test */ + public function it_builds_subprocess_argv_with_flags_and_options(): void + { + $executor = $this->app->make(CommandExecutor::class); + + $line = $executor->buildSubprocessCommandLine([ + 'command' => 'down', + '--force' => true, + '--retry' => 60, + '--secret' => 'abc', + ]); + + $this->assertSame('down', $line[2]); + $this->assertContains('--force', $line); + $this->assertContains('--retry=60', $line); + $this->assertContains('--secret=abc', $line); + $this->assertContains('--no-interaction', $line); + } +} diff --git a/tests/Services/ArtisanRunner/InteractiveQuestionBrokerTest.php b/tests/Services/ArtisanRunner/InteractiveQuestionBrokerTest.php new file mode 100644 index 000000000..c751a742b --- /dev/null +++ b/tests/Services/ArtisanRunner/InteractiveQuestionBrokerTest.php @@ -0,0 +1,32 @@ +publishAnswer('run-1', 'prompt-1', 'yes'); + + $this->assertSame('yes', $broker->waitForAnswer('run-1', 'prompt-1')); + } + + /** @test */ + public function it_times_out_when_no_answer_arrives(): void + { + $broker = new InteractiveQuestionBroker(1); + + $this->expectException(ArtisanRunnerException::class); + $this->expectExceptionMessage('Timed out waiting for interactive prompt answer'); + + $broker->waitForAnswer('run-timeout', 'prompt-timeout'); + } +} diff --git a/tests/Services/SystemConsole/SystemConsoleConfigTest.php b/tests/Services/SystemConsole/SystemConsoleConfigTest.php new file mode 100644 index 000000000..2a39bff12 --- /dev/null +++ b/tests/Services/SystemConsole/SystemConsoleConfigTest.php @@ -0,0 +1,241 @@ + [ + 'default' => [ + 'label' => 'Default maintenance', + 'options' => [ + 'render' => 'modularous::maintenance', + 'retry' => 60, + 'refresh' => 30, + 'secret' => 'test-secret', + ], + ], + 'minimal' => [ + 'label' => 'Minimal', + 'options' => [ + 'retry' => 15, + 'secret' => '', + 'redirect' => null, + ], + ], + ], + 'modularous.system_console.default_down_preset' => 'default', + 'modularous.system_console.cache_commands' => [ + [ + 'command' => 'route:clear', + 'label' => 'Route Clear', + 'confirm' => false, + 'color' => 'warning', + 'icon' => 'mdi-routes', + ], + [ + 'command' => 'optimize', + 'label' => 'Optimize', + 'confirm' => true, + ], + [ + 'label' => 'Missing command', + ], + ], + ]); + } + + /** @test */ + public function it_maps_down_presets_and_omits_empty_options(): void + { + $presets = SystemConsoleConfig::downPresetsForUi(); + + $this->assertCount(2, $presets); + $this->assertSame('default', $presets[0]['key']); + $this->assertSame('Default maintenance', $presets[0]['label']); + $this->assertSame([ + 'render' => 'modularous::maintenance', + 'retry' => 60, + 'refresh' => 30, + 'secret' => 'test-secret', + ], $presets[0]['options']); + + $this->assertSame('minimal', $presets[1]['key']); + $this->assertSame(['retry' => 15], $presets[1]['options']); + } + + /** @test */ + public function it_resolves_default_down_preset_key(): void + { + $this->assertSame('default', SystemConsoleConfig::defaultDownPresetKey()); + + config(['modularous.system_console.default_down_preset' => 'missing']); + $this->assertSame('default', SystemConsoleConfig::defaultDownPresetKey()); + } + + /** @test */ + public function it_uses_config_defaults_when_system_settings_unavailable(): void + { + $this->assertFalse(app()->bound('system.settings')); + + $presets = SystemConsoleConfig::downPresetsForUi(); + + $this->assertSame([ + 'render' => 'modularous::maintenance', + 'retry' => 60, + 'refresh' => 30, + 'secret' => 'test-secret', + ], $presets[0]['options']); + $this->assertSame([], SystemConsoleConfig::downPresetOverridesFromSettings()); + } + + /** @test */ + public function it_overrides_default_preset_options_from_system_settings(): void + { + $this->bindSystemSettings([ + 'down_presets' => [ + 'render' => 'custom::maintenance', + 'retry' => 120, + 'refresh' => 45, + 'secret' => 'settings-secret', + ], + ]); + + $presets = SystemConsoleConfig::downPresetsForUi(); + + $this->assertSame([ + 'render' => 'custom::maintenance', + 'retry' => 120, + 'refresh' => 45, + 'secret' => 'settings-secret', + ], $presets[0]['options']); + + // Non-default presets remain config-only. + $this->assertSame(['retry' => 15], $presets[1]['options']); + } + + /** @test */ + public function it_merges_partial_system_settings_overrides_onto_config_defaults(): void + { + $this->bindSystemSettings([ + 'down_presets' => [ + 'retry' => 90, + 'secret' => '', + 'render' => null, + ], + ]); + + $presets = SystemConsoleConfig::downPresetsForUi(); + + $this->assertSame([ + 'render' => 'modularous::maintenance', + 'retry' => 90, + 'refresh' => 30, + 'secret' => 'test-secret', + ], $presets[0]['options']); + } + + /** @test */ + public function it_maps_cache_commands_and_skips_invalid_entries(): void + { + $commands = SystemConsoleConfig::cacheCommandsForUi(); + + $this->assertCount(2, $commands); + $this->assertSame('route:clear', $commands[0]['command']); + $this->assertFalse($commands[0]['confirm']); + $this->assertSame('warning', $commands[0]['color']); + $this->assertSame('tonal', $commands[0]['variant']); + + $this->assertSame('optimize', $commands[1]['command']); + $this->assertTrue($commands[1]['confirm']); + $this->assertNull($commands[1]['color']); + } + + /** @test */ + public function it_deduplicates_cache_commands_by_command_name(): void + { + config([ + 'modularous.system_console.cache_commands' => [ + [ + 'command' => 'route:clear', + 'label' => 'Route Clear', + ], + [ + 'command' => 'optimize', + 'label' => 'Optimize A', + ], + [ + 'command' => 'route:clear', + 'label' => 'Route Clear Duplicate', + ], + [ + 'command' => 'optimize', + 'label' => 'Optimize B', + ], + ], + ]); + + $commands = SystemConsoleConfig::cacheCommandsForUi(); + + $this->assertCount(2, $commands); + $this->assertSame(['route:clear', 'optimize'], array_column($commands, 'command')); + $this->assertSame('Optimize A', $commands[1]['label']); + } + + /** @test */ + public function it_normalizes_options(): void + { + $this->assertSame( + ['retry' => 60, 'enabled' => true], + SystemConsoleConfig::normalizeOptions([ + 'retry' => 60, + 'secret' => '', + 'redirect' => null, + 'enabled' => true, + 'nested' => ['nope'], + ]) + ); + } + + /** @test */ + public function it_reports_maintenance_status(): void + { + $this->assertIsBool(SystemConsoleConfig::isInMaintenance()); + } + + /** + * @param array $snapshot + */ + private function bindSystemSettings(array $snapshot): void + { + $this->app->instance('system.settings', new class($snapshot) + { + /** @param array $snapshot */ + public function __construct(private array $snapshot) {} + + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + $segments = $key === '' ? [] : explode('.', $key); + $cursor = $this->snapshot; + + foreach ($segments as $segment) { + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return $default; + } + $cursor = $cursor[$segment]; + } + + return $cursor; + } + }); + } +} diff --git a/tests/View/Widgets/SystemConsoleWidgetTest.php b/tests/View/Widgets/SystemConsoleWidgetTest.php new file mode 100644 index 000000000..f1872d347 --- /dev/null +++ b/tests/View/Widgets/SystemConsoleWidgetTest.php @@ -0,0 +1,173 @@ + true, + 'modularous.artisan_runner.allowed_roles' => ['superadmin'], + 'modularous.system_console.down_presets' => [ + 'default' => [ + 'label' => 'Default maintenance', + 'options' => [ + 'render' => 'modularous::maintenance', + 'retry' => 60, + 'secret' => 'secret', + ], + ], + ], + 'modularous.system_console.default_down_preset' => 'default', + 'modularous.system_console.cache_commands' => [ + [ + 'command' => 'optimize:clear', + 'label' => 'Optimize Clear', + 'confirm' => true, + ], + ], + ]); + } + + /** @test */ + public function widget_can_be_instantiated(): void + { + $widget = new SystemConsoleWidget; + + $this->assertInstanceOf(SystemConsoleWidget::class, $widget); + $this->assertSame('ue-system-console', $widget->tag); + $this->assertSame('v-col', $widget->widgetTag); + } + + /** @test */ + public function hydrate_attributes_includes_console_payload_and_endpoints_shape(): void + { + $widget = new SystemConsoleWidget; + $result = $widget->hydrateAttributes([ + 'title' => 'System Console', + ]); + + $this->assertSame('System Console', $result['title']); + $this->assertTrue($result['runnerDisabled']); + $this->assertIsArray($result['endpoints']); + $this->assertArrayHasKey('commands', $result['endpoints']); + $this->assertArrayHasKey('run', $result['endpoints']); + $this->assertArrayHasKey('maintenanceMode', $result); + $this->assertIsBool($result['maintenanceMode']); + $this->assertSame('default', $result['defaultDownPreset']); + $this->assertCount(1, $result['downPresets']); + $this->assertSame('optimize:clear', $result['cacheCommands'][0]['command']); + } + + /** @test */ + public function use_widget_config_render_does_not_duplicate_cache_commands(): void + { + config([ + 'modularous.system_console.cache_commands' => [ + 'route:clear' => [ + 'command' => 'route:clear', + 'label' => 'Route Clear', + ], + 'optimize' => [ + 'command' => 'optimize', + 'label' => 'Optimize', + 'confirm' => true, + ], + ], + 'modularous.widgets.system-console' => [ + 'attributes' => [], + 'slots' => [], + ], + ]); + + $widget = new SystemConsoleWidget; + $widget->useWidgetConfig(true); + $widget->mergeAttributes([ + 'title' => 'System Console', + 'subtitle' => 'Maintenance mode and cache / optimize commands.', + ]); + + $result = $widget->render(); + $inner = $result['elements'][0] ?? null; + + $this->assertIsArray($inner); + $this->assertArrayHasKey('attributes', $inner); + + $cacheCommands = $inner['attributes']['cacheCommands'] ?? []; + $downPresets = $inner['attributes']['downPresets'] ?? []; + + $this->assertCount(2, $cacheCommands); + $this->assertSame(['route:clear', 'optimize'], array_column($cacheCommands, 'command')); + $this->assertCount(1, $downPresets); + $this->assertSame(['default'], array_column($downPresets, 'key')); + } + + /** @test */ + public function component_create_dashboard_block_does_not_duplicate_lists(): void + { + config([ + 'modularous.system_console.cache_commands' => [ + 'route:clear' => [ + 'command' => 'route:clear', + 'label' => 'Route Clear', + ], + 'route:cache' => [ + 'command' => 'route:cache', + 'label' => 'Route Cache', + ], + 'optimize:clear' => [ + 'command' => 'optimize:clear', + 'label' => 'Optimize Clear', + ], + 'optimize' => [ + 'command' => 'optimize', + 'label' => 'Optimize', + ], + ], + ]); + + $rendered = Component::create([ + 'widget' => 'SystemConsoleWidget', + 'widgetCol' => [ + 'cols' => 12, + 'lg' => 6, + ], + 'attributes' => [ + 'title' => 'System Console', + 'subtitle' => 'Maintenance mode and cache / optimize commands.', + 'elevation' => 2, + ], + ]); + + $inner = $rendered['elements'][0] ?? null; + $this->assertIsArray($inner); + + $cacheCommands = $inner['attributes']['cacheCommands'] ?? []; + $this->assertCount(4, $cacheCommands); + $this->assertSame( + ['route:clear', 'route:cache', 'optimize:clear', 'optimize'], + array_column($cacheCommands, 'command') + ); + } + + /** @test */ + public function render_returns_widget_structure(): void + { + $widget = new SystemConsoleWidget; + $result = $widget->render(); + + $this->assertIsArray($result); + $this->assertSame('v-col', $result['tag']); + $this->assertArrayHasKey('elements', $result); + $this->assertIsArray($result['elements']); + } +} diff --git a/vue/src/js/Pages/ArtisanRunner.vue b/vue/src/js/Pages/ArtisanRunner.vue new file mode 100644 index 000000000..ed095aa2e --- /dev/null +++ b/vue/src/js/Pages/ArtisanRunner.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/vue/src/js/components/shared/ArtisanRunner.vue b/vue/src/js/components/shared/ArtisanRunner.vue new file mode 100644 index 000000000..47106ac84 --- /dev/null +++ b/vue/src/js/components/shared/ArtisanRunner.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/vue/src/js/components/shared/SystemConsole.vue b/vue/src/js/components/shared/SystemConsole.vue new file mode 100644 index 000000000..5c81e3450 --- /dev/null +++ b/vue/src/js/components/shared/SystemConsole.vue @@ -0,0 +1,543 @@ + + + + + diff --git a/vue/src/js/hooks/index.js b/vue/src/js/hooks/index.js index 8562cc306..efaece4b3 100755 --- a/vue/src/js/hooks/index.js +++ b/vue/src/js/hooks/index.js @@ -63,4 +63,5 @@ export { default as useSvg } from './useSvg' export { default as useRandKey } from './useRandKey' export { default as useStepUpAwareJsonPost } from './useStepUpAwareJsonPost' +export { default as useArtisanRunner } from './useArtisanRunner' export { useSignedPublicPreview } from './useSignedPublicPreview' diff --git a/vue/src/js/hooks/useArtisanRunner.js b/vue/src/js/hooks/useArtisanRunner.js new file mode 100644 index 000000000..258a2ee7f --- /dev/null +++ b/vue/src/js/hooks/useArtisanRunner.js @@ -0,0 +1,324 @@ +import { computed, ref } from 'vue' +import axios from 'axios' + +/** + * ArtisanRunner panel API: catalog, definition, SSE run stream, interactive answers. + * + * @param {{ commands: string, definition: string, run: string, answer: string }} endpoints + */ +export default function useArtisanRunner (endpoints) { + const commands = ref([]) + const loadingCommands = ref(false) + const loadingDefinition = ref(false) + const running = ref(false) + const selectedCommand = ref(null) + const definition = ref(null) + const formArguments = ref({}) + const formOptions = ref({}) + const output = ref('') + const exitCode = ref(null) + const runId = ref(null) + const currentPrompt = ref(null) + const promptAnswer = ref('') + const errorMessage = ref(null) + + let abortController = null + + const definitionUrl = computed(() => { + if (!endpoints?.definition || !selectedCommand.value) { + return '' + } + return endpoints.definition.replace('__NAME__', encodeURIComponent(selectedCommand.value)) + }) + + function answerUrl (id) { + if (!endpoints?.answer) { + return '' + } + return endpoints.answer.replace('__RUN_ID__', encodeURIComponent(id)) + } + + async function loadCommands () { + if (!endpoints?.commands) { + return + } + loadingCommands.value = true + errorMessage.value = null + try { + const { data } = await axios.get(endpoints.commands) + commands.value = Array.isArray(data?.commands) ? data.commands : [] + } catch (e) { + errorMessage.value = e.response?.data?.message ?? e.message ?? 'Failed to load commands' + commands.value = [] + } finally { + loadingCommands.value = false + } + } + + async function loadDefinition (name) { + selectedCommand.value = name + definition.value = null + formArguments.value = {} + formOptions.value = {} + if (!name || !endpoints?.definition) { + return + } + loadingDefinition.value = true + errorMessage.value = null + try { + const url = endpoints.definition.replace('__NAME__', encodeURIComponent(name)) + const { data } = await axios.get(url) + definition.value = data + const args = {} + for (const arg of data.arguments || []) { + args[arg.name] = arg.default ?? (arg.is_array ? '' : '') + } + formArguments.value = args + const opts = {} + for (const opt of data.options || []) { + if (opt.field === 'switch') { + opts[opt.name] = !!opt.default + } else { + opts[opt.name] = opt.default ?? '' + } + } + formOptions.value = opts + } catch (e) { + errorMessage.value = e.response?.data?.message ?? e.message ?? 'Failed to load definition' + } finally { + loadingDefinition.value = false + } + } + + function validateRequired () { + if (!definition.value) { + return 'Select a command first.' + } + for (const arg of definition.value.arguments || []) { + if (!arg.required) { + continue + } + const value = formArguments.value[arg.name] + if (value === null || value === undefined || String(value).trim() === '') { + return `Argument "${arg.name}" is required.` + } + } + for (const opt of definition.value.options || []) { + if (!opt.required) { + continue + } + const value = formOptions.value[opt.name] + if (value === null || value === undefined || String(value).trim() === '') { + return `Option "--${opt.name}" is required.` + } + } + return null + } + + function parseSseChunk (buffer, onEvent) { + const parts = buffer.split('\n\n') + const rest = parts.pop() ?? '' + for (const part of parts) { + if (!part.trim()) { + continue + } + let event = 'message' + const dataLines = [] + for (const line of part.split('\n')) { + if (line.startsWith('event:')) { + event = line.slice(6).trim() + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()) + } + } + const raw = dataLines.join('\n') + let payload = raw + try { + payload = JSON.parse(raw) + } catch { + // keep raw string + } + onEvent(event, payload) + } + return rest + } + + /** + * Run a known command with explicit args/options (skips definition form validation). + * Used by SystemConsole and other fixed-action UIs. + * + * @param {string} command + * @param {Record} [args] + * @param {Record} [opts] + */ + async function runDirect (command, args = {}, opts = {}) { + if (!command || !endpoints?.run) { + return false + } + + selectedCommand.value = command + formArguments.value = { ...args } + formOptions.value = { ...opts } + definition.value = { + name: command, + description: '', + arguments: [], + options: [], + } + + return runCommand({ skipValidation: true }) + } + + /** + * @param {{ skipValidation?: boolean }} [opts] + */ + async function runCommand (opts = {}) { + const skipValidation = Boolean(opts?.skipValidation) + if (!skipValidation) { + const validationError = validateRequired() + if (validationError) { + errorMessage.value = validationError + return false + } + } + if (!endpoints?.run || !selectedCommand.value) { + return false + } + + running.value = true + output.value = '' + exitCode.value = null + runId.value = null + currentPrompt.value = null + promptAnswer.value = '' + errorMessage.value = null + abortController = new AbortController() + + try { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + const response = await fetch(endpoints.run, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'X-Requested-With': 'XMLHttpRequest', + ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), + }, + credentials: 'same-origin', + signal: abortController.signal, + body: JSON.stringify({ + command: selectedCommand.value, + arguments: formArguments.value, + options: formOptions.value, + }), + }) + + if (!response.ok) { + let message = `Run failed (${response.status})` + try { + const json = await response.json() + message = json.message || message + } catch { + // ignore + } + errorMessage.value = message + running.value = false + return false + } + + const reader = response.body?.getReader() + if (!reader) { + errorMessage.value = 'Streaming is not supported in this browser.' + running.value = false + return false + } + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + buffer += decoder.decode(value, { stream: true }) + buffer = parseSseChunk(buffer, (event, payload) => { + if (event === 'started') { + runId.value = payload?.runId ?? null + } else if (event === 'output') { + output.value += payload?.chunk ?? '' + } else if (event === 'prompt') { + currentPrompt.value = payload + promptAnswer.value = payload?.default != null ? String(payload.default) : '' + } else if (event === 'error') { + errorMessage.value = payload?.message ?? 'Command error' + output.value += `\n[error] ${payload?.message ?? ''}\n` + } else if (event === 'done') { + exitCode.value = payload?.exitCode ?? null + currentPrompt.value = null + } + }) + } + } catch (e) { + if (e?.name !== 'AbortError') { + errorMessage.value = e.message ?? 'Run failed' + } + } finally { + running.value = false + abortController = null + } + + return true + } + + async function submitPromptAnswer () { + if (!runId.value || !currentPrompt.value?.id) { + return + } + const answeredPromptId = currentPrompt.value.id + const url = answerUrl(runId.value) + let answer = promptAnswer.value + if (currentPrompt.value.type === 'confirm') { + answer = ['1', 'true', 'yes', 'y', 'on'].includes(String(answer).toLowerCase().trim()) + } + await axios.post(url, { + prompt_id: answeredPromptId, + answer, + }) + // A follow-up prompt may arrive over SSE while we awaited the answer POST. + // Only clear if we are still showing the prompt we just answered. + if (currentPrompt.value?.id === answeredPromptId) { + currentPrompt.value = null + promptAnswer.value = '' + } + } + + function abortRun () { + abortController?.abort() + } + + return { + commands, + loadingCommands, + loadingDefinition, + running, + selectedCommand, + definition, + formArguments, + formOptions, + output, + exitCode, + runId, + currentPrompt, + promptAnswer, + errorMessage, + definitionUrl, + loadCommands, + loadDefinition, + validateRequired, + runCommand, + runDirect, + submitPromptAnswer, + abortRun, + } +}