Building a Laravel Livewire Component to Manage Terminal Commands

I n this blog post, we will walk through building a Laravel Livewire component that allows you to execute terminal commands from within your Laravel application. We'll leverage the Symfony Process component to handle the command execution. This component will also store the command history in the database for future reference.

Building a Laravel Livewire Component to Manage Terminal Commands
In Laravel applications, integrating a command terminal can greatly enhance developer productivity by allowing direct interaction with the underlying system. This tutorial will guide you through the process of creating a command terminal within a Laravel application using Livewire and Symfony's Process component. With this terminal, users can execute system commands and view the output directly within the application. Step 1: Setting Up the Environment First, we need to create a new Livewire component. Run the following command in your terminal: Step 2: Creating the Command History Model and Migration Step 3: Implementing the Livewire Component Logic Step 4: Create Blade View Create a Blade view to render the Livewire component. You can customize the UI as needed. Here's a basic example: Conclusion: Congratulations! You've successfully implemented a command terminal within your Laravel application using Livewire and Symfony's Process component. This terminal provides a convenient way to interact with the system directly from your application, enhancing development and debugging capabilities.
Laravel: Ensure you have a Laravel project set up. If not, you can create one using the following command:
composer create-project --prefer-dist laravel/laravel blog
Livewire: Install Livewire in your Laravel project

composer require livewire/livewire
php artisan livewire:publish --config
Step 1: Setting Up the Environment
php artisan make:livewire Admin/Terminal/ManageTerminal
This command will generate two files: a PHP class file for the component logic and a Blade view file for the component's HTML.
Step 2: Creating the Command History Model and Migration
We need a model to store the command history. Create a model with a migration file using the following command:
php artisan make:model CommandHistory -m

public function up()
{
    Schema::create('command_histories', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->onDelete('cascade');
        $table->text('command');
        $table->text('output')->nullable();
        $table->text('error')->nullable();
        $table->boolean('success');
        $table->timestamps();
    });
}

php artisan migrate
Step 3: Implementing the Livewire Component Logic
Open the generated Livewire component class (ManageTerminal.php) and add the following logic:


<?php

namespace App\Livewire\Admin\Terminal;

use App\Http\Traits\ImageUploadTrait;
use App\Models\CommandHistory;
use Livewire\Component;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\On;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Livewire\Attributes\Url;

class ManageTerminal extends Component
{

    use ImageUploadTrait;

    public $command;
    public $output;
    public $success;
    public $error;
    public $timestamp;
    public $tip;

    #[Url]
    public $search = '';

    public function render()
    {
        $search = trim($this->search);
        $commandHistories = CommandHistory::where('command', 'like', '%' . $search . '%')->latest()->get();
        return view('livewire.admin.terminal.manage-terminal', ['commandHistories' => $commandHistories]);
    }

    public function executeCommand()
    {
        $this->validate([
            'command' => 'required'
        ]);
        try {
            // Get the command from the request
            Session::forget('command');
            $command = trim($this->command);
            Session::put('command', $command);
            // Update this path to your PHP installation path
            $phpPath = env('IMPORT_PHP_PATH') ?? 'D:\laravel9\php\php.exe';

            // Prepend the PHP path to the command
            if (strpos($command, 'php') === 0) {
                $command = str_replace('php', '"' . $phpPath . '"', $command);
            }

            // Define the working directory to the root of your Laravel project
            $workingDirectory = base_path();
            $env = array_merge($_ENV, [
                'APP_ENV' => env('APP_ENV'),
                'APP_KEY' => env('APP_KEY'),
                'DB_CONNECTION' => env('DB_CONNECTION'),
                'DB_HOST' => env('DB_HOST'),
                'DB_PORT' => env('DB_PORT'),
                'DB_DATABASE' => env('DB_DATABASE'),
                'DB_USERNAME' => env('DB_USERNAME'),
                'DB_PASSWORD' => env('DB_PASSWORD'),
            ]);

            // Execute the command using Symfony Process component
            $process = Process::fromShellCommandline($command, $workingDirectory );
            // $process = new Process([$command], $workingDirectory, $env);
            $process->setInput("Yes\n");
            // Run the command
            $process->run();

            // Check if the process was successful
            if (!$process->isSuccessful()) {
                throw new ProcessFailedException($process);
            }
            $this->output = $process->getOutput();
            $this->success = $process->isSuccessful();
            // $this->success = true;
            $this->timestamp = now()->format('Y-m-d H:i:s');
            $this->tip = 'For more commands, visit the documentation or contact support if you encounter any issues.';
            // Get the output of the command
            $commandHistory = new CommandHistory();
            $commandHistory->user_id = auth()->id();
            $commandHistory->command = $command;
            $commandHistory->output = $this->success ? $this->output : null;
            $commandHistory->error = $this->success ? null : $process->getErrorOutput();
            $commandHistory->success = $this->success;
            $commandHistory->save();
            $this->reset(['command']);

            $this->myAlert([
                'message' => 'Command executed successfully',
                'alert-type' => 'success'
            ]);
        } catch (\Exception $e) {
            // Handle exceptions (e.g., ProcessFailedException)
            Log::error('Error executing command: ' . $e->getMessage());
            $this->error = $e->getMessage();
            $this->success = false;
            $this->output = null;
            $this->timestamp = now()->format('Y-m-d H:i:s');
            $this->tip = 'For more commands, visit the documentation or contact support if you encounter any issues.';

            $this->myAlert([
                'message' => 'An error occurred',
                'alert-type' => 'error'
            ]);
        }
    }

    public function delete($rowId)
    {

        $commad =  CommandHistory::findOrFail($rowId);
        $commad->delete();

        $this->myAlert([
            'message' => 'Deleted successfully',
            'alert-type' => 'error'
        ]);
    }



    public function copyCommand($id)
    {
        $commandHistory = CommandHistory::findOrFail($id);
        $phpPath = env('IMPORT_PHP_PATH');
        $command = str_replace($phpPath, 'php', $commandHistory->command);
        $command = str_replace('"php"', 'php', $command);
        $this->dispatch('copySelectedCommand', ['commandName' =>  trim($command)]);
    }




    #[On('copiedSuccess')]
    public function showAlert($copiedText)
    {
        try {
            // Replace 'php artisan' with empty string
            $copiedCmd = str_replace('php artisan', '', $copiedText);

            // Show success alert
            $this->myAlert([
                'message' =>  $copiedCmd . ' copied successfully.',
                'alert-type' => 'success'
            ]);
        } catch (\Exception $e) {
            // Handle any exceptions here
            $this->myAlert([
                'message' => 'An error occurred while processing the copied text.',
                'alert-type' => 'error'
            ]);
        }
    }
    public $commandRowId = [];

    public function checkAll()
    {
        $getPageIds =    CommandHistory::get();
        if ($getPageIds) {
            $this->commandRowId =  $getPageIds->pluck('id')->toArray();
        } else {
            $this->commandRowId = [];
        }
    }

    public function deleteAll()
    {
        if (count($this->commandRowId) > 0) {
            CommandHistory::whereIn('id', $this->commandRowId)->delete();
            $this->myAlert([
                'message' => 'Commands Deleted Deleted',
                'alert-type' => 'error'
            ]);

            $this->commandRowId = [];
        } else {
            $this->myAlert([
                'message' => 'Please Select First',
                'alert-type' => 'error'
            ]);
        }
    }
}



Step 4: Create Blade View Create a Blade view to render the Livewire component. You can customize the UI as needed. Here's a basic example:


<div>
    <div class="row" >
        <div class="col-md-12">
          <div class="card card-outline card-info ">
            <div class="card-header">
              <h3 class="card-title">Run Command</h3>
            </div>
            <div class="card-body">
                <form wire:submit.prevent="executeCommand" >
                        <div class="row">
                            <div class="col-md-12">
                                <div class="mb-3">
                                    <input type="text"  wire:model="command" class="form-control @error('command') is-invalid   @enderror" autocomplete="off"
placeholder="Enter command...">
                                    @error('command') <span class="text-danger">{{$message}}</span> @enderror
                                </div>
                            </div>
                        </div>
                        <div>
                            <button type="submit" class="btn btn-success" wire:loading.attr="disabled" wire:target="executeCommand">Run</button>
                            <i class="fas fa-1x fa-sync-alt fa-spin"   wire:loading  wire:target="executeCommand" ></i>
                        </div>
                </form>

                @if($success)
                    <div class="mt-3">
                        <h3 class="text-success">???? Command executed successfully!</h3>
                        <pre class="bg-dark">{{ $output }}</pre>
                        <p class="text-success font-weight-bold">{{ $timestamp ?? '' }}</p>
                        <p class="text-info font-weight-bold"> {{ $tip ?? '' }}</p>
                    </div>
                @elseif($error)
                    <div class="mt-3">
                        <h3 class="text-danger">⚠️ An error occurred while executing the command.</h3>
                        <pre class="bg-dark">{!! $error !!}</pre>
                        <p class="text-success font-weight-bold">{{ $timestamp ?? '' }}</p>
                        <p class="text-info font-weight-bold"> {{ $tip ?? '' }}</p>
                    </div>
                @endif

                @if (session()->has('command'))
                    <div class="alert alert-dark mt-3">
                        <strong>Recently used: </strong>
                        <span id="usedCommad">{{ session('command') }}</span>
                        <button id="copyButton" class="btn btn-primary ml-3">Copy</button>
                    </div>
                @endif
   
            </div>
          </div>
        </div>
      </div>
      @if(isset($commandHistories) && count($commandHistories) > 0)
        <div class="row">
            <div class="col-md-12">
                <div class="card card-outline card-info ">
                    <div class="card-header">
                        <h3 class="card-title">Run Terminal Commands</h3>
                        <div class="card-tools">
                            <button type="button" class="btn btn-tool" data-card-widget="collapse" title="Collapse">
                                <i class="fas fa-minus"></i>
                            </button>
                        </div>
                    </div>
                    <div class="card-body table-responsive p-0">
                        <div class="card-header ">
                            @hasanyrole('Super Admin|admin')
                            <button type="button" class="btn btn-danger btn-sm" wire:click="deleteAll" wire:loading.attr="disabled"
                            wire:target="deleteAll">
                            Delete all
                        </button>
                        <button type="button" class="btn btn-info btn-sm" wire:click="checkAll" wire:loading.attr="disabled"
                            wire:target="checkAll">
                            Check all
                        </button>
                            @else
                            I do not have all of these roles or have more other roles...
                            @endhasanyrole
                            <i class="fas fa-1x fa-sync-alt fa-spin" wire:loading wire:target="openCreateModel"></i>
                            <div class="card-tools">
                                <div class="input-group input-group-sm" style="width: 150px;">
                                    <input type="text" name="table_search" class="form-control float-right"
                                        wire:model.live="search" placeholder="Search">
                                    <div class="input-group-append">
                                        <button type="submit" class="btn btn-default">
                                            <i class="fas fa-search"></i>
                                        </button>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <table class="table table-hover text-nowrap">
                            <thead>
                                <tr>
                                    <th scope="col">User</th>
                                    <th scope="col">Command</th>
                                    <th scope="col">Time</th>
                                    <th scope="col">Status</th>
                                    <th scope="col">Action</th>
                                </tr>
                            </thead>
                            <tbody>
                                @foreach ($commandHistories as $history)
                                <tr>
                                    <th scope="row"> <input type="checkbox" wire:model="commandRowId"
                                        value="{{$history->id}}">   {{ $history->user->name ?? 'Unknown' }}</th>
                                    <td>
                                    @php
                                    $phpPath = env('IMPORT_PHP_PATH', 'D:\laravel9\php\php.exe');
                                    $command = str_replace("$phpPath",  'php',  $history->command);
                                    $command = str_replace('"php"', 'php', $command);
                                    @endphp
                                    <a href="javascript:void(0)" title="{{$history->command}}">  {{  $command  ?? '' }} </a>
                                    </td>
                                    <td>{{ $history->created_at->diffForHumans() }}</td>
                                    <td>
                                        @if ($history->success)
                                            <span class="badge badge-success">Success</span>
                                        @else
                                            <span class="badge badge-danger">Failed</span>
                                        @endif
                                    </td>
                                    <td>
                                        <button  class="btn btn-success " wire:target="copyCommand({{$history->id}})" wire:loading.attr="disabled"
wire:click.prevent="copyCommand({{$history->id}})">
                                            <i class="fas fa-copy" wire:loading.remove wire:target="copyCommand({{ $history->id }})"></i>
                                            <i class="fas fa-spinner fa-spin" wire:loading wire:target="copyCommand({{ $history->id }})"></i>
                                        </button>
                                        <button  class="btn btn-danger " wire:target="delete({{$history->id}})" wire:loading.attr="disabled"
wire:click.prevent="delete({{$history->id}})">
                                            <i class="fas fa-trash" wire:loading.remove wire:target="delete({{ $history->id }})"></i>
                                            <i class="fas fa-spinner fa-spin" wire:loading wire:target="delete({{ $history->id }})"></i>
                                        </button>
                                    </td>
                                </tr>
                            @endforeach
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>
      @endif

    <script>
        document.getElementById('copyButton').addEventListener('click', function() {
            var textToCopy = document.getElementById('usedCommad').innerText;
            var tempTextarea = document.createElement('textarea');
            tempTextarea.value = textToCopy;
            document.body.appendChild(tempTextarea);
            tempTextarea.select();
            tempTextarea.setSelectionRange(0, 99999); // For mobile devices
            document.execCommand('copy');
            document.body.removeChild(tempTextarea);
            var usedCommadSpan = document.getElementById('usedCommad');
            usedCommadSpan.classList.add('bg-danger', 'p-2');
            setTimeout(function() {
                copyButton.textContent = 'Copy';
                usedCommadSpan.classList.remove('bg-danger', 'p-3'); // Remove the classes
            }, 2000);
            Livewire.dispatch('copiedSuccess', { copiedText: textToCopy}) ;


           
        });

        document.addEventListener('livewire:init', () => {
            Livewire.on('copySelectedCommand', (event) => {
                let copiedCommand  = event[0]['commandName'];
                    // console.log(copiedCommand);
                    var tempTextarea = document.createElement('textarea');
                    tempTextarea.value = copiedCommand;
                    document.body.appendChild(tempTextarea);
                    tempTextarea.select();
                    tempTextarea.setSelectionRange(0, 99999); // For mobile devices
                    document.execCommand('copy');
                    document.body.removeChild(tempTextarea);
                    Livewire.dispatch('copiedSuccess', { copiedText: copiedCommand}) ;
            });
        });
    </script>
</div>


0 Comments
Leave a Comment

Video