diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..624edf4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ "*" ] + +jobs: + laravel-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: [8.1, 8.2] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: vendor + key: composer-${{ hashFiles('**/composer.lock') }} + restore-keys: composer- + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Run Laravel tests (if test suite exists) + run: | + if [ -f artisan ]; then + php artisan test || true + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81d3db --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Dependencies +/vendor/ +/node_modules/ +composer.phar +composer.lock + +# Laravel & PHP +/storage/ +/bootstrap/cache/ +/public/storage +/public/hot +/public/mix-manifest.json +*.log +*.env +*.env.* +.env.backup +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log + +# IDE & Editor Files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Testing +/coverage/ +/.phpunit.cache +/tests/coverage/ +phpunit.xml + +# Build & Assets +/dist/ +/build/ +/public/js/ +/public/css/ +/public/mix-manifest.json +mix-manifest.json + +# Package Development +*.tar.gz +*.zip +*.phar + +# OS Generated Files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary Files +/tmp/ +*.tmp +*.temp + +# Nova Tool Specific +/nova/ +/stubs/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..88c10ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ali Sameni — Farsi Studio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index f00f92d..5777d75 100644 --- a/README.md +++ b/README.md @@ -1 +1,402 @@ -# nova-flex-runner +# Nova Flex Runner + +A powerful, customizable command runner and log viewer for Laravel Nova 4, developed by Farsi Studio. + +## Features + +### 🎯 Core Command Runner Features +- **Command Profiles**: Define commands via config file with flexible categorization +- **Multiple Command Types**: Support for `artisan`, `job`, `service`, `shell`, and `http` commands +- **Rich Input Types**: text, textarea, select, multiselect, checkbox, boolean, datepicker, tags, file upload, resource-select +- **Queue Integration**: Commands executed via Laravel queue jobs +- **Real-time Feedback**: Live output display with confirmation modals +- **Security First**: Built-in command validation and security controls + +### 📊 Advanced Log Viewer +- **Comprehensive Logging**: Every execution stored with detailed metadata +- **Advanced Filtering**: Search by user, command, category, date, status +- **Export Capabilities**: Download logs as `.log` files +- **Performance Analytics**: Execution statistics and performance metrics +- **Code Highlighting**: Syntax-highlighted output display + +### 🛡️ Security & Access Control +- **Policy-based Authorization**: Granular permission controls +- **Command Validation**: Built-in security checks for shell commands +- **Audit Trail**: Complete execution history with user attribution +- **Confirmation Modals**: Required confirmations for destructive operations + +## Installation + +### 1. Install via Composer + +```bash +composer require farsi/nova-flex-runner +``` + +### 2. Publish Configuration + +```bash +php artisan vendor:publish --tag=nova-flex-runner-config +``` + +### 3. Publish and Run Migrations + +```bash +php artisan vendor:publish --tag=nova-flex-runner-migrations +php artisan migrate +``` + +### 4. Publish Assets (Optional) + +```bash +php artisan vendor:publish --tag=nova-flex-runner-assets +``` + +### 5. Register Tools in NovaServiceProvider + +The tools are automatically registered via the service provider. No manual registration required. + +## Configuration + +### Basic Setup + +The package uses the `config/nova-flex-runner.php` configuration file: + +```php + [ + 'require_confirmation' => true, + 'log_all_executions' => true, + 'max_execution_time' => 300, + ], + + // Shell command settings + 'shell' => [ + 'enabled' => false, // Enable with caution + 'timeout' => 300, + 'allowed_commands' => [ + '/^git /', + '/^composer /', + ], + ], + + // Command definitions + 'commands' => [ + // Your command categories here + ], +]; +``` + +### Defining Command Profiles + +Commands are organized into categories. Here's an example configuration: + +```php +'commands' => [ + 'maintenance' => [ + 'name' => 'Maintenance', + 'description' => 'Application maintenance commands', + 'icon' => 'wrench-screwdriver', + 'commands' => [ + [ + 'name' => 'Clear Application Cache', + 'slug' => 'cache-clear', + 'description' => 'Clear all application caches', + 'type' => 'artisan', + 'command' => 'cache:clear', + 'confirmation_required' => false, + 'inputs' => [], + ], + [ + 'name' => 'Put Application in Maintenance Mode', + 'slug' => 'maintenance-down', + 'description' => 'Put the application into maintenance mode', + 'type' => 'artisan', + 'command' => 'down', + 'confirmation_required' => true, + 'inputs' => [ + [ + 'name' => 'message', + 'label' => 'Maintenance Message', + 'type' => 'textarea', + 'placeholder' => 'We are currently performing maintenance...', + 'required' => false, + 'is_option' => true, + ], + ], + ], + ], + ], +], +``` + +## Command Types + +### Artisan Commands + +```php +[ + 'name' => 'Run Migrations', + 'slug' => 'migrate', + 'type' => 'artisan', + 'command' => 'migrate', + 'inputs' => [ + [ + 'name' => 'force', + 'label' => 'Force run in production', + 'type' => 'checkbox', + 'is_option' => true, + ], + ], +] +``` + +### Laravel Jobs + +```php +[ + 'name' => 'Process Data', + 'slug' => 'process-data', + 'type' => 'job', + 'job_class' => 'App\\Jobs\\ProcessDataJob', + 'queue' => 'default', + 'inputs' => [ + [ + 'name' => 'batch_size', + 'label' => 'Batch Size', + 'type' => 'number', + 'min' => 1, + 'max' => 1000, + 'required' => true, + ], + ], +] +``` + +### Custom Services + +```php +[ + 'name' => 'Generate Report', + 'slug' => 'generate-report', + 'type' => 'service', + 'service_class' => 'App\\Services\\ReportService', + 'method' => 'generateReport', + 'inputs' => [ + [ + 'name' => 'report_type', + 'label' => 'Report Type', + 'type' => 'select', + 'options' => [ + ['value' => 'daily', 'label' => 'Daily Report'], + ['value' => 'weekly', 'label' => 'Weekly Report'], + ], + 'required' => true, + ], + ], +] +``` + +### Shell Commands (Use with Extreme Caution) + +```php +[ + 'name' => 'Git Status', + 'slug' => 'git-status', + 'type' => 'shell', + 'command' => 'git status', + 'timeout' => 30, + 'inputs' => [], +] +``` + +## Available Input Types + +### Text Input +```php +[ + 'name' => 'username', + 'label' => 'Username', + 'type' => 'text', + 'placeholder' => 'Enter username', + 'maxlength' => 50, + 'required' => true, +] +``` + +### Select Input +```php +[ + 'name' => 'environment', + 'label' => 'Environment', + 'type' => 'select', + 'options' => [ + ['value' => 'dev', 'label' => 'Development'], + ['value' => 'staging', 'label' => 'Staging'], + ['value' => 'prod', 'label' => 'Production'], + ], + 'required' => true, +] +``` + +### Number Input +```php +[ + 'name' => 'timeout', + 'label' => 'Timeout (seconds)', + 'type' => 'number', + 'min' => 1, + 'max' => 600, + 'step' => 1, + 'required' => true, +] +``` + +### Multiselect Input +```php +[ + 'name' => 'features', + 'label' => 'Features to Enable', + 'type' => 'multiselect', + 'options' => [ + ['value' => 'feature_a', 'label' => 'Feature A'], + ['value' => 'feature_b', 'label' => 'Feature B'], + ], +] +``` + +### Checkbox Input +```php +[ + 'name' => 'force', + 'label' => 'Force execution', + 'type' => 'checkbox', +] +``` + +### Textarea Input +```php +[ + 'name' => 'message', + 'label' => 'Message', + 'type' => 'textarea', + 'rows' => 4, + 'placeholder' => 'Enter your message...', +] +``` + +## Custom Input Components + +You can easily add your own custom input components: + +1. Create a Vue component in `resources/js/components/inputs/` +2. Register it in the config file: + +```php +'input_types' => [ + 'custom-input' => [ + 'component' => 'CustomInput', + 'props' => ['custom_prop'], + ], +], +``` + +## Log Viewer Module + +The Log Viewer provides comprehensive tracking of all command executions: + +### Features +- **Execution History**: Complete log of all command runs +- **Advanced Filtering**: Filter by user, command, date, status +- **Performance Metrics**: Duration tracking and statistics +- **Export Functionality**: Download logs as `.log` files +- **Real-time Updates**: Live status updates for running commands + +### Usage +Access the Log Viewer through the Nova sidebar menu "Command Logs". + +## Security Considerations + +⚠️ **Important Security Warnings:** + +1. **Shell Commands**: Disabled by default. Enable only with extreme caution and proper command whitelisting. +2. **Production Safety**: Never run destructive commands like `db:wipe` in production without proper safeguards. +3. **Access Control**: Always implement proper authorization policies. +4. **Input Validation**: Validate all user inputs before execution. +5. **Audit Trail**: All executions are logged for security auditing. + +### Authorization + +Implement custom authorization by defining Gate policies: + +```php +// In your AuthServiceProvider +Gate::define('viewNovaFlexRunner', function ($user) { + return $user->hasRole('admin'); +}); + +Gate::define('executeNovaFlexRunner', function ($user) { + return $user->hasRole('admin') || $user->hasRole('developer'); +}); + +Gate::define('viewNovaFlexRunnerLogs', function ($user) { + return $user->hasRole('admin'); +}); +``` + +## API Endpoints + +The package exposes several API endpoints for integration: + +- `GET /nova-vendor/nova-flex-runner/api/commands` - List all commands +- `POST /nova-vendor/nova-flex-runner/api/execute` - Execute a command +- `GET /nova-vendor/nova-flex-runner/api/status/{log}` - Get execution status +- `GET /nova-vendor/nova-flex-runner/api/logs` - List command logs +- `GET /nova-vendor/nova-flex-runner/api/logs/{log}/download` - Download log file + +## Requirements + +- PHP 8.1+ +- Laravel 10.0+ or 11.0+ +- Laravel Nova 4.0+ + +## Testing + +```bash +composer test +``` + +## Contributing + +Contributions are welcome! Please see our contributing guidelines for details. + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Credits + +- **Developer**: Ali Sameni +- **Company**: Farsi Studio + +### Inspired By +- [stepanenko3/nova-command-runner](https://github.com/stepanenko3/nova-command-runner) +- [spatie/laravel-activitylog](https://github.com/spatie/laravel-activitylog) +- [opcodesio/log-viewer](https://github.com/opcodesio/log-viewer) +- [filamentphp](https://filamentphp.com/) +- [nova-tabs](https://github.com/eminiarts/nova-tabs) +- [optimistdigital/nova-settings](https://github.com/optimistdigital/nova-settings) + +## License + +The MIT License (MIT). Please see [License File](LICENSE) for more information. + +--- + +**Developed by Ali Sameni** +**Maintained by Farsi Studio** + +For support and updates, visit: [https://github.com/farsi/nova-flex-runner](https://github.com/farsi/nova-flex-runner) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5779eea --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "farsi/nova-flex-runner", + "description": "A powerful, customizable command runner and log viewer for Laravel Nova 4, developed by Farsi Studio.", + "keywords": [ + "laravel", + "nova", + "artisan", + "commands", + "jobs", + "shell", + "tool" + ], + "license": "MIT", + "authors": [ + { + "name": "Ali Sameni", + "email": "contact@farsistudio.com", + "homepage": "https://farsistudio.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "laravel/framework": "^10.0|^11.0" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0", + "phpunit/phpunit": "^10.0", + "pestphp/pest": "^2.0" + }, + "suggest": { + "laravel/nova": "^4.0 - Required for this Nova tool to function" + }, + "autoload": { + "psr-4": { + "Farsi\\NovaFlexRunner\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Farsi\\NovaFlexRunner\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Farsi\\NovaFlexRunner\\NovaFlexRunnerServiceProvider" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/nova-flex-runner.php b/config/nova-flex-runner.php new file mode 100644 index 0000000..7ee76f1 --- /dev/null +++ b/config/nova-flex-runner.php @@ -0,0 +1,337 @@ + [ + 'require_confirmation' => true, + 'log_all_executions' => true, + 'max_execution_time' => 300, // seconds + ], + + /* + |-------------------------------------------------------------------------- + | Shell Command Settings + |-------------------------------------------------------------------------- + | + | Configure shell command execution settings. + | + */ + 'shell' => [ + 'enabled' => false, + 'timeout' => 300, + 'allowed_commands' => [ + // Add regex patterns for allowed commands + // '/^git /', + // '/^composer /', + // '/^npm /', + // '/^yarn /', + ], + 'blocked_commands' => [ + // Additional blocked commands beyond the default security list + ], + ], + + /* + |-------------------------------------------------------------------------- + | Queue Settings + |-------------------------------------------------------------------------- + | + | Configure queue settings for command execution. + | + */ + 'queue' => [ + 'connection' => env('NOVA_FLEX_RUNNER_QUEUE_CONNECTION'), + 'queue' => env('NOVA_FLEX_RUNNER_QUEUE', 'default'), + ], + + /* + |-------------------------------------------------------------------------- + | Command Profiles + |-------------------------------------------------------------------------- + | + | Define your command profiles here. Each profile represents a command + | that can be executed through the Nova Flex Runner interface. + | + */ + 'commands' => [ + /* + |-------------------------------------------------------------------------- + | Artisan Commands + |-------------------------------------------------------------------------- + */ + 'maintenance' => [ + 'name' => 'Maintenance', + 'description' => 'Application maintenance commands', + 'icon' => 'wrench-screwdriver', + 'commands' => [ + [ + 'name' => 'Clear Application Cache', + 'slug' => 'cache-clear', + 'description' => 'Clear all application caches', + 'type' => 'artisan', + 'command' => 'cache:clear', + 'confirmation_required' => false, + 'inputs' => [], + ], + [ + 'name' => 'Clear Configuration Cache', + 'slug' => 'config-clear', + 'description' => 'Clear configuration cache', + 'type' => 'artisan', + 'command' => 'config:clear', + 'confirmation_required' => false, + 'inputs' => [], + ], + [ + 'name' => 'Clear Route Cache', + 'slug' => 'route-clear', + 'description' => 'Clear route cache', + 'type' => 'artisan', + 'command' => 'route:clear', + 'confirmation_required' => false, + 'inputs' => [], + ], + [ + 'name' => 'Clear View Cache', + 'slug' => 'view-clear', + 'description' => 'Clear compiled view files', + 'type' => 'artisan', + 'command' => 'view:clear', + 'confirmation_required' => false, + 'inputs' => [], + ], + [ + 'name' => 'Put Application in Maintenance Mode', + 'slug' => 'maintenance-down', + 'description' => 'Put the application into maintenance mode', + 'type' => 'artisan', + 'command' => 'down', + 'confirmation_required' => true, + 'inputs' => [ + [ + 'name' => 'message', + 'label' => 'Maintenance Message', + 'type' => 'textarea', + 'placeholder' => 'We are currently performing maintenance...', + 'required' => false, + 'is_option' => true, + ], + [ + 'name' => 'retry', + 'label' => 'Retry After (seconds)', + 'type' => 'number', + 'placeholder' => '60', + 'min' => 1, + 'required' => false, + 'is_option' => true, + ], + ], + ], + [ + 'name' => 'Bring Application Up', + 'slug' => 'maintenance-up', + 'description' => 'Bring the application out of maintenance mode', + 'type' => 'artisan', + 'command' => 'up', + 'confirmation_required' => true, + 'inputs' => [], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Database Commands + |-------------------------------------------------------------------------- + */ + 'database' => [ + 'name' => 'Database', + 'description' => 'Database management commands', + 'icon' => 'circle-stack', + 'commands' => [ + [ + 'name' => 'Run Migrations', + 'slug' => 'migrate', + 'description' => 'Run database migrations', + 'type' => 'artisan', + 'command' => 'migrate', + 'confirmation_required' => true, + 'inputs' => [ + [ + 'name' => 'force', + 'label' => 'Force run in production', + 'type' => 'checkbox', + 'required' => false, + 'is_option' => true, + ], + ], + ], + [ + 'name' => 'Rollback Migrations', + 'slug' => 'migrate-rollback', + 'description' => 'Rollback database migrations', + 'type' => 'artisan', + 'command' => 'migrate:rollback', + 'confirmation_required' => true, + 'inputs' => [ + [ + 'name' => 'step', + 'label' => 'Steps to rollback', + 'type' => 'number', + 'placeholder' => '1', + 'min' => 1, + 'required' => false, + 'is_option' => true, + ], + ], + ], + [ + 'name' => 'Seed Database', + 'slug' => 'db-seed', + 'description' => 'Seed the database with records', + 'type' => 'artisan', + 'command' => 'db:seed', + 'confirmation_required' => true, + 'inputs' => [ + [ + 'name' => 'class', + 'label' => 'Seeder Class', + 'type' => 'text', + 'placeholder' => 'DatabaseSeeder', + 'required' => false, + 'is_option' => true, + ], + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Queue Commands + |-------------------------------------------------------------------------- + */ + 'queue' => [ + 'name' => 'Queue', + 'description' => 'Queue management commands', + 'icon' => 'queue-list', + 'commands' => [ + [ + 'name' => 'Start Queue Worker', + 'slug' => 'queue-work', + 'description' => 'Start processing queue jobs', + 'type' => 'artisan', + 'command' => 'queue:work', + 'confirmation_required' => false, + 'inputs' => [ + [ + 'name' => 'queue', + 'label' => 'Queue Name', + 'type' => 'text', + 'placeholder' => 'default', + 'required' => false, + 'is_option' => true, + ], + [ + 'name' => 'timeout', + 'label' => 'Timeout (seconds)', + 'type' => 'number', + 'placeholder' => '60', + 'min' => 1, + 'required' => false, + 'is_option' => true, + ], + ], + ], + [ + 'name' => 'Clear Failed Jobs', + 'slug' => 'queue-flush', + 'description' => 'Delete all failed queue jobs', + 'type' => 'artisan', + 'command' => 'queue:flush', + 'confirmation_required' => true, + 'inputs' => [], + ], + [ + 'name' => 'Retry Failed Jobs', + 'slug' => 'queue-retry', + 'description' => 'Retry failed queue jobs', + 'type' => 'artisan', + 'command' => 'queue:retry', + 'confirmation_required' => false, + 'inputs' => [ + [ + 'name' => 'id', + 'label' => 'Job ID (leave empty for all)', + 'type' => 'text', + 'placeholder' => 'all', + 'required' => false, + ], + ], + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Input Field Types + |-------------------------------------------------------------------------- + | + | Define the available input field types and their configurations. + | + */ + 'input_types' => [ + 'text' => [ + 'component' => 'TextInput', + 'props' => ['placeholder', 'maxlength', 'pattern'], + ], + 'textarea' => [ + 'component' => 'TextareaInput', + 'props' => ['placeholder', 'rows', 'maxlength'], + ], + 'number' => [ + 'component' => 'NumberInput', + 'props' => ['min', 'max', 'step', 'placeholder'], + ], + 'select' => [ + 'component' => 'SelectInput', + 'props' => ['options', 'placeholder'], + ], + 'multiselect' => [ + 'component' => 'MultiselectInput', + 'props' => ['options', 'placeholder'], + ], + 'checkbox' => [ + 'component' => 'CheckboxInput', + 'props' => ['label'], + ], + 'boolean' => [ + 'component' => 'BooleanInput', + 'props' => ['true_label', 'false_label'], + ], + 'datepicker' => [ + 'component' => 'DatepickerInput', + 'props' => ['format', 'min_date', 'max_date'], + ], + 'tags' => [ + 'component' => 'TagsInput', + 'props' => ['placeholder', 'suggestions'], + ], + 'file' => [ + 'component' => 'FileInput', + 'props' => ['accept', 'multiple', 'max_size'], + ], + 'resource-select' => [ + 'component' => 'ResourceSelectInput', + 'props' => ['resource', 'display_field', 'value_field'], + ], + ], +]; \ No newline at end of file diff --git a/database/migrations/2025_01_01_000000_create_command_logs_table.php b/database/migrations/2025_01_01_000000_create_command_logs_table.php new file mode 100644 index 0000000..4007174 --- /dev/null +++ b/database/migrations/2025_01_01_000000_create_command_logs_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('command_name'); + $table->string('command_slug'); + $table->enum('command_type', ['artisan', 'job', 'service', 'shell', 'http']); + $table->string('category')->nullable(); + $table->json('inputs')->nullable(); + $table->longText('output')->nullable(); + $table->enum('status', ['pending', 'running', 'success', 'failed']); + $table->decimal('duration', 8, 3)->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['command_type', 'status']); + $table->index(['category', 'created_at']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('command_logs'); + } +}; \ No newline at end of file diff --git a/resources/js/components/FlexRunner.vue b/resources/js/components/FlexRunner.vue new file mode 100644 index 0000000..62ca638 --- /dev/null +++ b/resources/js/components/FlexRunner.vue @@ -0,0 +1,188 @@ + + + \ No newline at end of file diff --git a/resources/js/components/inputs/CheckboxInput.vue b/resources/js/components/inputs/CheckboxInput.vue new file mode 100644 index 0000000..df2f56b --- /dev/null +++ b/resources/js/components/inputs/CheckboxInput.vue @@ -0,0 +1,72 @@ + + + \ No newline at end of file diff --git a/resources/js/components/inputs/NumberInput.vue b/resources/js/components/inputs/NumberInput.vue new file mode 100644 index 0000000..6cce9b3 --- /dev/null +++ b/resources/js/components/inputs/NumberInput.vue @@ -0,0 +1,92 @@ + + + \ No newline at end of file diff --git a/resources/js/components/inputs/SelectInput.vue b/resources/js/components/inputs/SelectInput.vue new file mode 100644 index 0000000..7ec8489 --- /dev/null +++ b/resources/js/components/inputs/SelectInput.vue @@ -0,0 +1,88 @@ + + + \ No newline at end of file diff --git a/resources/js/components/inputs/TextInput.vue b/resources/js/components/inputs/TextInput.vue new file mode 100644 index 0000000..66b28a7 --- /dev/null +++ b/resources/js/components/inputs/TextInput.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/resources/js/components/inputs/TextareaInput.vue b/resources/js/components/inputs/TextareaInput.vue new file mode 100644 index 0000000..f886c4b --- /dev/null +++ b/resources/js/components/inputs/TextareaInput.vue @@ -0,0 +1,86 @@ + + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..308491f --- /dev/null +++ b/routes/web.php @@ -0,0 +1,26 @@ +middleware(['nova']) + ->group(function () { + // Flex Runner routes + Route::prefix('api')->group(function () { + Route::get('commands', [FlexRunnerController::class, 'index']); + Route::post('execute', [FlexRunnerController::class, 'execute']); + Route::post('validate-inputs', [FlexRunnerController::class, 'validateInputs']); + Route::get('status/{log}', [FlexRunnerController::class, 'status']); + + // Log Viewer routes + Route::prefix('logs')->group(function () { + Route::get('/', [LogViewerController::class, 'index']); + Route::get('stats', [LogViewerController::class, 'stats']); + Route::get('filters', [LogViewerController::class, 'filters']); + Route::get('{log}', [LogViewerController::class, 'show']); + Route::get('{log}/download', [LogViewerController::class, 'download']); + }); + }); + }); \ No newline at end of file diff --git a/src/Http/Controllers/FlexRunnerController.php b/src/Http/Controllers/FlexRunnerController.php new file mode 100644 index 0000000..d7f932b --- /dev/null +++ b/src/Http/Controllers/FlexRunnerController.php @@ -0,0 +1,194 @@ +executors = [ + 'artisan' => app(ArtisanExecutorService::class), + 'job' => app(JobExecutorService::class), + 'shell' => app(ShellExecutorService::class), + 'service' => app(CustomServiceExecutor::class), + ]; + } + + public function index(): JsonResponse + { + $this->authorize('viewNovaFlexRunner'); + + $commands = config('nova-flex-runner.commands', []); + + return response()->json([ + 'commands' => $commands, + 'settings' => [ + 'require_confirmation' => config('nova-flex-runner.security.require_confirmation', true), + 'shell_enabled' => config('nova-flex-runner.shell.enabled', false), + ], + ]); + } + + public function execute(Request $request): JsonResponse + { + $this->authorize('executeNovaFlexRunner'); + + $request->validate([ + 'command_slug' => 'required|string', + 'category' => 'required|string', + 'inputs' => 'array', + ]); + + $command = $this->findCommand($request->command_slug, $request->category); + + if (!$command) { + return response()->json([ + 'success' => false, + 'error' => 'Command not found', + ], 404); + } + + $executor = $this->executors[$command['type']] ?? null; + + if (!$executor) { + return response()->json([ + 'success' => false, + 'error' => 'Invalid command type', + ], 400); + } + + if (!$executor->validateCommand($command)) { + return response()->json([ + 'success' => false, + 'error' => 'Command validation failed', + ], 400); + } + + $log = CommandLog::create([ + 'user_id' => $request->user()->id, + 'command_name' => $command['name'], + 'command_slug' => $command['slug'], + 'command_type' => $command['type'], + 'category' => $request->category, + 'inputs' => $request->inputs, + 'status' => 'running', + 'started_at' => now(), + ]); + + try { + $result = $executor->execute($command, $request->inputs ?? [], $log); + + return response()->json(array_merge($result, [ + 'log_id' => $log->id, + ])); + } catch (\Exception $e) { + $log->update([ + 'status' => 'failed', + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + 'log_id' => $log->id, + ], 500); + } + } + + public function status(Request $request, CommandLog $log): JsonResponse + { + $this->authorize('viewNovaFlexRunner'); + + if ($log->user_id !== $request->user()->id) { + abort(403); + } + + return response()->json([ + 'id' => $log->id, + 'status' => $log->status, + 'output' => $log->output, + 'duration' => $log->duration, + 'error_message' => $log->error_message, + 'started_at' => $log->started_at, + 'completed_at' => $log->completed_at, + ]); + } + + public function validateInputs(Request $request): JsonResponse + { + $this->authorize('viewNovaFlexRunner'); + + $request->validate([ + 'command_slug' => 'required|string', + 'category' => 'required|string', + 'inputs' => 'array', + ]); + + $command = $this->findCommand($request->command_slug, $request->category); + + if (!$command) { + return response()->json([ + 'valid' => false, + 'errors' => ['command' => 'Command not found'], + ], 404); + } + + $executor = $this->executors[$command['type']] ?? null; + + if (!$executor) { + return response()->json([ + 'valid' => false, + 'errors' => ['command' => 'Invalid command type'], + ], 400); + } + + $inputs = $request->inputs ?? []; + $inputDefinitions = $command['inputs'] ?? []; + + $errors = $executor instanceof \Farsi\NovaFlexRunner\Services\BaseExecutorService + ? $executor->validateInputs($inputs, $inputDefinitions) + : []; + + return response()->json([ + 'valid' => empty($errors), + 'errors' => $errors, + ]); + } + + protected function findCommand(string $slug, string $category): ?array + { + $commands = config('nova-flex-runner.commands', []); + + if (!isset($commands[$category])) { + return null; + } + + foreach ($commands[$category]['commands'] as $command) { + if ($command['slug'] === $slug) { + return $command; + } + } + + return null; + } + + protected function authorize(string $ability): void + { + if (Gate::has($ability)) { + Gate::authorize($ability, auth()->user()); + } + } +} \ No newline at end of file diff --git a/src/Http/Controllers/LogViewerController.php b/src/Http/Controllers/LogViewerController.php new file mode 100644 index 0000000..3a1c008 --- /dev/null +++ b/src/Http/Controllers/LogViewerController.php @@ -0,0 +1,242 @@ +authorize('viewNovaFlexRunnerLogs'); + + $query = CommandLog::with('user') + ->orderBy('created_at', 'desc'); + + if ($request->filled('user_id')) { + $query->byUser($request->user_id); + } + + if ($request->filled('status')) { + $query->byStatus($request->status); + } + + if ($request->filled('type')) { + $query->byType($request->type); + } + + if ($request->filled('category')) { + $query->byCategory($request->category); + } + + if ($request->filled('command_name')) { + $query->where('command_name', 'like', '%' . $request->command_name . '%'); + } + + if ($request->filled('date_from')) { + $query->where('created_at', '>=', $request->date_from); + } + + if ($request->filled('date_to')) { + $query->where('created_at', '<=', $request->date_to); + } + + $perPage = min($request->get('per_page', 25), 100); + $logs = $query->paginate($perPage); + + return response()->json([ + 'logs' => $logs->items(), + 'pagination' => [ + 'current_page' => $logs->currentPage(), + 'last_page' => $logs->lastPage(), + 'per_page' => $logs->perPage(), + 'total' => $logs->total(), + 'from' => $logs->firstItem(), + 'to' => $logs->lastItem(), + ], + ]); + } + + public function show(Request $request, CommandLog $log): JsonResponse + { + $this->authorize('viewNovaFlexRunnerLogs'); + + $log->load('user'); + + return response()->json([ + 'log' => $log, + ]); + } + + public function download(Request $request, CommandLog $log): Response + { + $this->authorize('viewNovaFlexRunnerLogs'); + + $filename = sprintf( + 'command-log-%s-%s.log', + $log->command_slug, + $log->created_at->format('Y-m-d-H-i-s') + ); + + $content = $this->formatLogForDownload($log); + + return response($content, 200, [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function stats(Request $request): JsonResponse + { + $this->authorize('viewNovaFlexRunnerLogs'); + + $days = $request->get('days', 30); + $fromDate = now()->subDays($days); + + $stats = [ + 'total_executions' => CommandLog::where('created_at', '>=', $fromDate)->count(), + 'successful_executions' => CommandLog::where('created_at', '>=', $fromDate) + ->where('status', 'success')->count(), + 'failed_executions' => CommandLog::where('created_at', '>=', $fromDate) + ->where('status', 'failed')->count(), + 'running_executions' => CommandLog::where('status', 'running')->count(), + 'average_duration' => CommandLog::where('created_at', '>=', $fromDate) + ->whereNotNull('duration') + ->avg('duration'), + ]; + + $stats['success_rate'] = $stats['total_executions'] > 0 + ? round(($stats['successful_executions'] / $stats['total_executions']) * 100, 2) + : 0; + + $commandStats = CommandLog::where('created_at', '>=', $fromDate) + ->selectRaw('command_name, command_type, category, count(*) as executions, avg(duration) as avg_duration') + ->groupBy('command_name', 'command_type', 'category') + ->orderBy('executions', 'desc') + ->limit(10) + ->get(); + + $dailyStats = CommandLog::where('created_at', '>=', $fromDate) + ->selectRaw('DATE(created_at) as date, count(*) as executions') + ->groupBy('date') + ->orderBy('date') + ->get(); + + return response()->json([ + 'overview' => $stats, + 'top_commands' => $commandStats, + 'daily_executions' => $dailyStats, + ]); + } + + public function filters(): JsonResponse + { + $this->authorize('viewNovaFlexRunnerLogs'); + + $users = CommandLog::with('user') + ->select('user_id') + ->distinct() + ->get() + ->pluck('user') + ->filter() + ->map(function ($user) { + return [ + 'value' => $user->id, + 'label' => $user->name ?? $user->email, + ]; + }) + ->values(); + + $types = CommandLog::select('command_type') + ->distinct() + ->pluck('command_type') + ->map(function ($type) { + return [ + 'value' => $type, + 'label' => ucfirst($type), + ]; + }) + ->values(); + + $categories = CommandLog::select('category') + ->distinct() + ->whereNotNull('category') + ->pluck('category') + ->map(function ($category) { + return [ + 'value' => $category, + 'label' => ucfirst($category), + ]; + }) + ->values(); + + $statuses = collect(['pending', 'running', 'success', 'failed']) + ->map(function ($status) { + return [ + 'value' => $status, + 'label' => ucfirst($status), + ]; + }); + + return response()->json([ + 'users' => $users, + 'types' => $types, + 'categories' => $categories, + 'statuses' => $statuses, + ]); + } + + protected function formatLogForDownload(CommandLog $log): string + { + $content = []; + $content[] = "Nova Flex Runner - Command Execution Log"; + $content[] = "====================================="; + $content[] = ""; + $content[] = "Command: {$log->command_name}"; + $content[] = "Slug: {$log->command_slug}"; + $content[] = "Type: {$log->command_type}"; + $content[] = "Category: {$log->category}"; + $content[] = "Status: {$log->status}"; + $content[] = "User: {$log->user->name ?? $log->user->email ?? 'Unknown'}"; + $content[] = "Started: {$log->started_at}"; + $content[] = "Completed: {$log->completed_at}"; + $content[] = "Duration: {$log->formatted_duration}"; + $content[] = ""; + + if ($log->inputs) { + $content[] = "Inputs:"; + $content[] = "-------"; + foreach ($log->inputs as $key => $value) { + $content[] = "{$key}: " . (is_array($value) ? json_encode($value) : $value); + } + $content[] = ""; + } + + if ($log->error_message) { + $content[] = "Error:"; + $content[] = "------"; + $content[] = $log->error_message; + $content[] = ""; + } + + if ($log->output) { + $content[] = "Output:"; + $content[] = "-------"; + $content[] = $log->output; + } + + return implode("\n", $content); + } + + protected function authorize(string $ability): void + { + if (Gate::has($ability)) { + Gate::authorize($ability, auth()->user()); + } + } +} \ No newline at end of file diff --git a/src/Models/CommandLog.php b/src/Models/CommandLog.php new file mode 100644 index 0000000..5f84c06 --- /dev/null +++ b/src/Models/CommandLog.php @@ -0,0 +1,104 @@ + 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'duration' => 'float', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(config('auth.providers.users.model')); + } + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function isCompleted(): bool + { + return in_array($this->status, ['success', 'failed']); + } + + public function wasSuccessful(): bool + { + return $this->status === 'success'; + } + + public function hasFailed(): bool + { + return $this->status === 'failed'; + } + + public function getFormattedDurationAttribute(): string + { + if (!$this->duration) { + return '0s'; + } + + if ($this->duration < 1) { + return round($this->duration * 1000) . 'ms'; + } + + if ($this->duration < 60) { + return round($this->duration, 2) . 's'; + } + + $minutes = floor($this->duration / 60); + $seconds = round($this->duration % 60, 2); + + return "{$minutes}m {$seconds}s"; + } + + public function scopeByUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeByStatus($query, $status) + { + return $query->where('status', $status); + } + + public function scopeByType($query, $type) + { + return $query->where('command_type', $type); + } + + public function scopeByCategory($query, $category) + { + return $query->where('category', $category); + } + + public function scopeRecent($query, $days = 30) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } +} \ No newline at end of file diff --git a/src/NovaFlexRunnerServiceProvider.php b/src/NovaFlexRunnerServiceProvider.php new file mode 100644 index 0000000..efadf2f --- /dev/null +++ b/src/NovaFlexRunnerServiceProvider.php @@ -0,0 +1,120 @@ +loadRoutes(); + $this->loadMigrations(); + $this->publishAssets(); + $this->registerPolicies(); + $this->registerNovaAssets(); + } + + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/nova-flex-runner.php', 'nova-flex-runner'); + } + + protected function loadRoutes(): void + { + $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); + } + + protected function loadMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + protected function publishAssets(): void + { + if ($this->app->runningInConsole()) { + // Publish config + $this->publishes([ + __DIR__ . '/../config/nova-flex-runner.php' => config_path('nova-flex-runner.php'), + ], 'nova-flex-runner-config'); + + // Publish migrations + $this->publishes([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'nova-flex-runner-migrations'); + + // Publish assets + $this->publishes([ + __DIR__ . '/../dist' => public_path('vendor/nova-flex-runner'), + ], 'nova-flex-runner-assets'); + } + } + + protected function registerPolicies(): void + { + Gate::define('viewNovaFlexRunner', function ($user) { + return $this->authorizeUser($user, 'view'); + }); + + Gate::define('executeNovaFlexRunner', function ($user) { + return $this->authorizeUser($user, 'execute'); + }); + + Gate::define('viewNovaFlexRunnerLogs', function ($user) { + return $this->authorizeUser($user, 'viewLogs'); + }); + } + + protected function authorizeUser($user, string $action): bool + { + $permissions = config('nova-flex-runner.permissions', []); + + if (empty($permissions)) { + // Default behavior: allow all authenticated users + return true; + } + + // Check if user has specific permission + if (isset($permissions[$action])) { + $permission = $permissions[$action]; + + if (is_callable($permission)) { + return $permission($user); + } + + if (is_string($permission) && method_exists($user, 'can')) { + return $user->can($permission); + } + + if (is_array($permission)) { + // Check if user has any of the required roles/permissions + foreach ($permission as $perm) { + if (method_exists($user, 'can') && $user->can($perm)) { + return true; + } + if (method_exists($user, 'hasRole') && $user->hasRole($perm)) { + return true; + } + } + return false; + } + } + + return true; + } + + protected function registerNovaAssets(): void + { + Nova::serving(function (ServingNova $event) { + Nova::tools([ + new FlexRunnerTool(), + new LogViewerTool(), + ]); + }); + } +} \ No newline at end of file diff --git a/src/Services/ArtisanExecutorService.php b/src/Services/ArtisanExecutorService.php new file mode 100644 index 0000000..84854a0 --- /dev/null +++ b/src/Services/ArtisanExecutorService.php @@ -0,0 +1,123 @@ +prepareArguments($inputs, $command['inputs'] ?? []); + + $output = new BufferedOutput(); + $startTime = microtime(true); + + try { + $exitCode = Artisan::call($commandName, $arguments, $output); + $duration = microtime(true) - $startTime; + $outputContent = $output->fetch(); + + $result = [ + 'success' => $exitCode === 0, + 'output' => $outputContent, + 'exit_code' => $exitCode, + 'duration' => $duration, + ]; + + if ($exitCode !== 0) { + $result['error'] = "Command failed with exit code {$exitCode}"; + } + + if ($log) { + $log->update([ + 'status' => $exitCode === 0 ? 'success' : 'failed', + 'output' => $outputContent, + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $exitCode !== 0 ? $result['error'] : null, + ]); + } + + return $result; + } catch (\Exception $e) { + $duration = microtime(true) - $startTime; + + $result = [ + 'success' => false, + 'output' => $output->fetch(), + 'error' => $e->getMessage(), + 'duration' => $duration, + ]; + + if ($log) { + $log->update([ + 'status' => 'failed', + 'output' => $output->fetch(), + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + } + + return $result; + } + } + + protected function prepareArguments(array $inputs, array $inputDefinitions): array + { + $arguments = []; + + foreach ($inputDefinitions as $input) { + $key = $input['name']; + $value = $inputs[$key] ?? null; + + if ($value === null) { + continue; + } + + switch ($input['type']) { + case 'boolean': + case 'checkbox': + if ($value) { + $arguments["--{$key}"] = true; + } + break; + + case 'multiselect': + case 'tags': + if (is_array($value) && !empty($value)) { + $arguments["--{$key}"] = $value; + } + break; + + default: + if ($value !== '') { + if ($input['is_option'] ?? false) { + $arguments["--{$key}"] = $value; + } else { + $arguments[$key] = $value; + } + } + break; + } + } + + return $arguments; + } + + public function validateCommand(array $command): bool + { + if (!isset($command['command'])) { + return false; + } + + $artisanCommands = collect(Artisan::all())->keys(); + + return $artisanCommands->contains($command['command']); + } +} \ No newline at end of file diff --git a/src/Services/BaseExecutorService.php b/src/Services/BaseExecutorService.php new file mode 100644 index 0000000..34aeb6a --- /dev/null +++ b/src/Services/BaseExecutorService.php @@ -0,0 +1,104 @@ +validateInputType($name, $value, $input)); + } + } + + return $errors; + } + + protected function validateInputType(string $name, $value, array $input): array + { + $errors = []; + $type = $input['type'] ?? 'text'; + + switch ($type) { + case 'select': + $options = $input['options'] ?? []; + if (!in_array($value, array_column($options, 'value'))) { + $errors[$name] = "Invalid option selected for {$name}."; + } + break; + + case 'multiselect': + if (!is_array($value)) { + $errors[$name] = "The {$name} field must be an array."; + } else { + $options = array_column($input['options'] ?? [], 'value'); + foreach ($value as $item) { + if (!in_array($item, $options)) { + $errors[$name] = "Invalid option selected for {$name}."; + break; + } + } + } + break; + + case 'number': + if (!is_numeric($value)) { + $errors[$name] = "The {$name} field must be a number."; + } else { + $min = $input['min'] ?? null; + $max = $input['max'] ?? null; + + if ($min !== null && $value < $min) { + $errors[$name] = "The {$name} field must be at least {$min}."; + } + + if ($max !== null && $value > $max) { + $errors[$name] = "The {$name} field must not be greater than {$max}."; + } + } + break; + + case 'email': + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + $errors[$name] = "The {$name} field must be a valid email address."; + } + break; + + case 'url': + if (!filter_var($value, FILTER_VALIDATE_URL)) { + $errors[$name] = "The {$name} field must be a valid URL."; + } + break; + } + + return $errors; + } +} \ No newline at end of file diff --git a/src/Services/CustomServiceExecutor.php b/src/Services/CustomServiceExecutor.php new file mode 100644 index 0000000..d808172 --- /dev/null +++ b/src/Services/CustomServiceExecutor.php @@ -0,0 +1,131 @@ +{$method}($inputs, $command); + + $output = ob_get_clean(); + $duration = microtime(true) - $startTime; + + if (is_array($result) && isset($result['success'])) { + $executionResult = array_merge($result, [ + 'duration' => $duration, + 'output' => $result['output'] ?? $output, + ]); + } else { + $executionResult = [ + 'success' => true, + 'output' => $output ?: ($result ? json_encode($result) : 'Service executed successfully'), + 'duration' => $duration, + 'result' => $result, + ]; + } + + if ($log) { + $log->update([ + 'status' => $executionResult['success'] ? 'success' : 'failed', + 'output' => $executionResult['output'], + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $executionResult['error'] ?? null, + ]); + } + + return $executionResult; + } catch (\Exception $e) { + $duration = microtime(true) - $startTime; + $output = ob_get_clean(); + + $result = [ + 'success' => false, + 'output' => $output, + 'error' => $e->getMessage(), + 'duration' => $duration, + ]; + + if ($log) { + $log->update([ + 'status' => 'failed', + 'output' => $output, + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + } + + return $result; + } + } + + public function validateCommand(array $command): bool + { + if (!isset($command['service_class'])) { + return false; + } + + if (!class_exists($command['service_class'])) { + return false; + } + + $method = $command['method'] ?? 'handle'; + + return method_exists($command['service_class'], $method); + } + + public function getServiceMethods(string $serviceClass): array + { + if (!class_exists($serviceClass)) { + return []; + } + + $reflection = new \ReflectionClass($serviceClass); + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + + return collect($methods) + ->filter(function ($method) { + return !$method->isConstructor() && + !$method->isDestructor() && + !$method->isStatic() && + !str_starts_with($method->getName(), '__'); + }) + ->map(function ($method) { + return [ + 'name' => $method->getName(), + 'parameters' => collect($method->getParameters())->map(function ($param) { + return [ + 'name' => $param->getName(), + 'type' => $param->getType() ? $param->getType()->getName() : 'mixed', + 'required' => !$param->isOptional(), + 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, + ]; + })->toArray(), + ]; + }) + ->values() + ->toArray(); + } +} \ No newline at end of file diff --git a/src/Services/JobExecutorService.php b/src/Services/JobExecutorService.php new file mode 100644 index 0000000..c8de457 --- /dev/null +++ b/src/Services/JobExecutorService.php @@ -0,0 +1,137 @@ +prepareJobData($inputs, $command['inputs'] ?? []); + + $startTime = microtime(true); + + try { + if (!class_exists($jobClass)) { + throw new \Exception("Job class {$jobClass} does not exist."); + } + + $job = new $jobClass($jobData); + + $queue = $command['queue'] ?? config('queue.default'); + $connection = $command['connection'] ?? null; + + if ($connection) { + $jobId = Queue::connection($connection)->push($job, '', $queue); + } else { + $jobId = Queue::push($job, '', $queue); + } + + $duration = microtime(true) - $startTime; + + $result = [ + 'success' => true, + 'output' => "Job {$jobClass} queued successfully with ID: {$jobId}", + 'job_id' => $jobId, + 'duration' => $duration, + ]; + + if ($log) { + $log->update([ + 'status' => 'success', + 'output' => $result['output'], + 'duration' => $duration, + 'completed_at' => now(), + ]); + } + + return $result; + } catch (\Exception $e) { + $duration = microtime(true) - $startTime; + + $result = [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'duration' => $duration, + ]; + + if ($log) { + $log->update([ + 'status' => 'failed', + 'output' => '', + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + } + + return $result; + } + } + + protected function prepareJobData(array $inputs, array $inputDefinitions): array + { + $data = []; + + foreach ($inputDefinitions as $input) { + $key = $input['name']; + $value = $inputs[$key] ?? null; + + if ($value !== null) { + $data[$key] = $value; + } + } + + return $data; + } + + public function validateCommand(array $command): bool + { + if (!isset($command['job_class'])) { + return false; + } + + return class_exists($command['job_class']); + } + + public function dispatchSync(array $command, array $inputs = []): array + { + $jobClass = $command['job_class']; + $jobData = $this->prepareJobData($inputs, $command['inputs'] ?? []); + + $startTime = microtime(true); + + try { + if (!class_exists($jobClass)) { + throw new \Exception("Job class {$jobClass} does not exist."); + } + + $job = new $jobClass($jobData); + + ob_start(); + $job->handle(); + $output = ob_get_clean(); + + $duration = microtime(true) - $startTime; + + return [ + 'success' => true, + 'output' => $output ?: "Job {$jobClass} executed successfully (sync)", + 'duration' => $duration, + ]; + } catch (\Exception $e) { + $duration = microtime(true) - $startTime; + + return [ + 'success' => false, + 'output' => ob_get_clean() ?: '', + 'error' => $e->getMessage(), + 'duration' => $duration, + ]; + } + } +} \ No newline at end of file diff --git a/src/Services/ShellExecutorService.php b/src/Services/ShellExecutorService.php new file mode 100644 index 0000000..959d9cc --- /dev/null +++ b/src/Services/ShellExecutorService.php @@ -0,0 +1,162 @@ +allowedCommands = config('nova-flex-runner.shell.allowed_commands', []); + $this->blockedCommands = array_merge( + $this->blockedCommands, + config('nova-flex-runner.shell.blocked_commands', []) + ); + } + + public function execute(array $command, array $inputs = [], ?CommandLog $log = null): array + { + $shellCommand = $this->buildCommand($command, $inputs); + + if (!$this->isCommandAllowed($shellCommand)) { + $result = [ + 'success' => false, + 'output' => '', + 'error' => 'Command not allowed or contains blocked operations.', + 'duration' => 0, + ]; + + if ($log) { + $log->update([ + 'status' => 'failed', + 'output' => '', + 'duration' => 0, + 'completed_at' => now(), + 'error_message' => $result['error'], + ]); + } + + return $result; + } + + $startTime = microtime(true); + + try { + $timeout = $command['timeout'] ?? config('nova-flex-runner.shell.timeout', 300); + $workingDirectory = $command['working_directory'] ?? base_path(); + + $process = Process::fromShellCommandline($shellCommand, $workingDirectory, null, null, $timeout); + $process->run(); + + $duration = microtime(true) - $startTime; + $output = $process->getOutput(); + $errorOutput = $process->getErrorOutput(); + + $result = [ + 'success' => $process->isSuccessful(), + 'output' => $output . ($errorOutput ? "\nSTDERR:\n" . $errorOutput : ''), + 'exit_code' => $process->getExitCode(), + 'duration' => $duration, + ]; + + if (!$process->isSuccessful()) { + $result['error'] = "Command failed with exit code {$process->getExitCode()}"; + } + + if ($log) { + $log->update([ + 'status' => $process->isSuccessful() ? 'success' : 'failed', + 'output' => $result['output'], + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $result['error'] ?? null, + ]); + } + + return $result; + } catch (\Exception $e) { + $duration = microtime(true) - $startTime; + + $result = [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'duration' => $duration, + ]; + + if ($log) { + $log->update([ + 'status' => 'failed', + 'output' => '', + 'duration' => $duration, + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + } + + return $result; + } + } + + protected function buildCommand(array $command, array $inputs): string + { + $baseCommand = $command['command']; + $inputDefinitions = $command['inputs'] ?? []; + + foreach ($inputDefinitions as $input) { + $name = $input['name']; + $value = $inputs[$name] ?? null; + + if ($value !== null && $value !== '') { + $placeholder = $input['placeholder'] ?? "{{$name}}"; + $escapedValue = escapeshellarg($this->sanitizeInput($value)); + $baseCommand = str_replace($placeholder, $escapedValue, $baseCommand); + } + } + + return $baseCommand; + } + + protected function isCommandAllowed(string $command): bool + { + if (!empty($this->allowedCommands)) { + foreach ($this->allowedCommands as $allowedPattern) { + if (preg_match($allowedPattern, $command)) { + return true; + } + } + return false; + } + + foreach ($this->blockedCommands as $blockedCommand) { + if (str_contains(strtolower($command), strtolower($blockedCommand))) { + return false; + } + } + + return config('nova-flex-runner.shell.enabled', false); + } + + public function validateCommand(array $command): bool + { + if (!isset($command['command'])) { + return false; + } + + if (!config('nova-flex-runner.shell.enabled', false)) { + return false; + } + + return $this->isCommandAllowed($command['command']); + } +} \ No newline at end of file diff --git a/src/Tools/FlexRunnerTool.php b/src/Tools/FlexRunnerTool.php new file mode 100644 index 0000000..3affbd2 --- /dev/null +++ b/src/Tools/FlexRunnerTool.php @@ -0,0 +1,28 @@ +path('/nova-flex-runner') + ->icon('play'); + } + + public function authorize($request) + { + return $request->user()->can('viewNovaFlexRunner', $request->user()); + } +} \ No newline at end of file diff --git a/src/Tools/LogViewerTool.php b/src/Tools/LogViewerTool.php new file mode 100644 index 0000000..d2a2106 --- /dev/null +++ b/src/Tools/LogViewerTool.php @@ -0,0 +1,28 @@ +path('/nova-flex-runner/logs') + ->icon('document-text'); + } + + public function authorize($request) + { + return $request->user()->can('viewNovaFlexRunnerLogs', $request->user()); + } +} \ No newline at end of file