diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index cd6bc17..494e68b 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -4,6 +4,8 @@ on: push: branches: [ main ] tags: [ 'v*' ] + pull_request: + branches: [ main ] workflow_dispatch: inputs: version: @@ -14,15 +16,19 @@ on: jobs: build-electron: runs-on: ${{ matrix.os }} + permissions: + contents: write strategy: matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] include: - os: ubuntu-latest platform: linux - os: windows-latest platform: win32 + - os: macos-latest + platform: darwin steps: - name: Checkout code @@ -35,6 +41,11 @@ jobs: cache: 'npm' cache-dependency-path: src/CommandRunner.ReactWebsite/package-lock.json + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Install dependencies working-directory: src/CommandRunner.ReactWebsite run: npm ci @@ -60,6 +71,14 @@ jobs: echo "This will create a .exe installer" npm run build-electron-win + - name: Build Electron app (macOS) + if: matrix.platform == 'darwin' + working-directory: src/CommandRunner.ReactWebsite + run: | + echo "Building macOS Electron app..." + echo "This will create a .dmg installer" + npm run build-electron-mac + - name: Verify build output (Linux) if: matrix.platform == 'linux' run: | @@ -107,6 +126,23 @@ jobs: Write-Host "The .exe file is a standard Windows installer" Write-Host "It includes the API server and will auto-start it when the app runs" + - name: Verify build output (macOS) + if: matrix.platform == 'darwin' + run: | + echo "=== MACOS BUILD VERIFICATION ===" + echo "Build output directory:" + ls -la src/CommandRunner.ReactWebsite/dist-electron/ + echo "" + echo "macOS installer files:" + find src/CommandRunner.ReactWebsite/dist-electron/ -name "*.dmg" -o -name "*.app" | head -10 + echo "" + echo "Package sizes:" + du -sh src/CommandRunner.ReactWebsite/dist-electron/*.dmg 2>/dev/null || echo " DMG not found" + echo "" + echo "=== MACOS INSTALLER INFO ===" + echo "The .dmg file is a standard macOS disk image" + echo "It includes the API server and will auto-start it when the app runs" + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -115,15 +151,38 @@ jobs: src/CommandRunner.ReactWebsite/dist-electron/ retention-days: 30 + - name: Create Pre-release (on PR) + if: github.event_name == 'pull_request' + uses: softprops/action-gh-release@v1 + with: + tag_name: pr-${{ github.event.pull_request.number }}-${{ matrix.platform }}-${{ github.run_number }} + name: PR #${{ github.event.pull_request.number }} pre-release (${{ matrix.platform }}) + target_commitish: ${{ github.sha }} + files: | + src/CommandRunner.ReactWebsite/dist-electron/*.exe + src/CommandRunner.ReactWebsite/dist-electron/*.deb + src/CommandRunner.ReactWebsite/dist-electron/*.AppImage + src/CommandRunner.ReactWebsite/dist-electron/*.dmg + generate_release_notes: false + draft: false + prerelease: true + overwrite_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create Release (on tag push) if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v1 with: files: | - src/CommandRunner.ReactWebsite/dist-electron/**/*.{exe,deb,AppImage,dmg} + src/CommandRunner.ReactWebsite/dist-electron/*.exe + src/CommandRunner.ReactWebsite/dist-electron/*.deb + src/CommandRunner.ReactWebsite/dist-electron/*.AppImage + src/CommandRunner.ReactWebsite/dist-electron/*.dmg generate_release_notes: true draft: false prerelease: false + overwrite_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -137,4 +196,4 @@ jobs: uses: igorjs/gh-actions-clean-workflow@v4 with: days_old: "7" - runs_to_keep: "5" \ No newline at end of file + runs_to_keep: "5" diff --git a/.github/workflows/reusable-build-and-unit-test.yml b/.github/workflows/reusable-build-and-unit-test.yml index 618e835..0a4b10a 100644 --- a/.github/workflows/reusable-build-and-unit-test.yml +++ b/.github/workflows/reusable-build-and-unit-test.yml @@ -14,20 +14,20 @@ jobs: include: # Ubuntu - os: ubuntu-latest - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' shell-test: bash # Windows with different shells - os: windows-latest - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' shell-test: powershell - os: windows-latest - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' shell-test: cmd # macOS - os: macos-latest - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' shell-test: bash steps: @@ -83,8 +83,8 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: test-results-${{ matrix.os }}-${{ matrix.dotnet-version }} + name: test-results-${{ matrix.os }}-${{ matrix.dotnet-version }}-${{ matrix.shell-test }} path: | **/TestResults/*.xml **/TestResults/*.html - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..659e521 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,42 @@ +{ + "version": "0.2.0", + "compounds": [ + { + "name": "Command Runner (Electron + API)", + "configurations": [ + "Command Runner API", + "Command Runner Electron" + ] + } + ], + "configurations": [ + { + "name": "Command Runner API", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/src/CommandRunner.Api/CommandRunner.Api.csproj" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + "name": "Command Runner Electron", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "electron-dev" + ], + "cwd": "${workspaceFolder}/src/CommandRunner.ReactWebsite", + "console": "integratedTerminal", + "skipFiles": [ + "/**" + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..bd29ec5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,47 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "command-runner:api", + "type": "shell", + "command": "dotnet run --project \"${workspaceFolder}/src/CommandRunner.Api/CommandRunner.Api.csproj\"", + "isBackground": true, + "problemMatcher": { + "owner": "command-runner-api", + "fileLocation": "absolute", + "pattern": [ + { + "regexp": "^(.*)$", + "file": 1, + "message": 1 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "^.*$", + "endsPattern": "Now listening on:\\s+" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "command-runner" + } + }, + { + "label": "command-runner:electron-dev", + "type": "npm", + "script": "electron-dev", + "path": "src/CommandRunner.ReactWebsite", + "dependsOn": [ + "command-runner:api" + ], + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "command-runner" + } + } + ] +} diff --git a/README.md b/README.md index 675f6db..a2e004e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Focus mode provides a distraction-free interface for intensive command execution ### ⚙️ **Profile Management** Create and manage command profiles with custom settings, environment variables, and execution parameters. +- Import profile files directly from JSON +- Reorder profiles and commands with drag-and-drop in the profile manager + ![Profile Settings Dialog](images/profile-settings.png) *Comprehensive profile configuration with environment variables and command settings* @@ -42,31 +45,33 @@ Easily switch between working directories with a user-friendly selector. ### 🔄 **Command Iteration** Run commands across multiple subdirectories recursively with configurable depth and error handling options. +### 📡 **Live Output Streaming** +See command output in real time while commands are still running, including iterative runs across directories. + ### 🌓 **Theme Support** Built-in dark and light themes for comfortable viewing in any environment or lighting condition. ## Upcoming Features -- **Progress Tracking**: Stream command outputs to the output window -- **Drag & Drop**: Reorder commands or profiles - **Keyboard Shortcuts**: Use keys to select and execute commands - **Multiple Execution**: Combine commands and run them together - **Multiple Directories**: Add multiple directories to a command for quick switching -- **Profile Import**: Get up and running faster by importing pre-built profiles ## Desktop App Installation +Desktop installers include the API backend and start it automatically when the app launches. You should not need to manually run the API in normal desktop usage. + ### Linux #### Option 1: Debian/Ubuntu (.deb) 1. Download the latest `.deb` file from the [Releases](https://github.com/GitHub-Kieran/command-runner/releases) page -2. Install using your package manager: `sudo dpkg -i command-runner_*.deb` +2. Install using your package manager: `sudo dpkg -i commandrunner-reactwebsite_*.deb` 3. If there are dependency issues, run: `sudo apt-get install -f` #### Option 2: AppImage 1. Download the latest `.AppImage` file from the [Releases](https://github.com/GitHub-Kieran/command-runner/releases) page -2. Make the file executable: `chmod +x Command-Runner-*.AppImage` -3. Run the AppImage: `./Command-Runner-*.AppImage` or right click and run +2. Make the file executable: `chmod +x CommandRunner-*.AppImage` +3. Run the AppImage: `./CommandRunner-*.AppImage` or right click and run #### Option 3: Other Linux Distributions For distributions not supporting .deb or AppImage: @@ -104,7 +109,7 @@ Profile data is stored as JSON files in these directories and persists between a - Node.js 18+ and npm - Git -- .NET 8.0 SDK (for API backend) +- .NET 10.0 SDK (for API/backend projects) ### Installation & Running @@ -114,14 +119,20 @@ Profile data is stored as JSON files in these directories and persists between a cd command-runner ``` -2. **Start the API server** (in a separate terminal): +2. **Start the app from VS Code (recommended)** + - Select launch profile: `Command Runner (Electron + API)` + - This starts both the API and Electron app together + +3. **Alternative manual start** (two terminals) + + **API server:** ```bash cd src/CommandRunner.Api dotnet run ``` The API will be available at `http://localhost:5081` -3. **Setup and start the frontend** (in another terminal): + **Frontend + Electron:** ```bash cd src/CommandRunner.ReactWebsite npm install @@ -148,6 +159,19 @@ npm run build-electron-win The built packages will be available in `src/CommandRunner.ReactWebsite/dist-electron/`. +## Example Profiles + +You can import ready-to-use profile files from [`examples/profiles`](examples/profiles): + +- [`dotnet-local-dev.json`](examples/profiles/dotnet-local-dev.json) +- [`javascript-tooling.json`](examples/profiles/javascript-tooling.json) +- [`windows-maintenance.json`](examples/profiles/windows-maintenance.json) + +Import flow: +1. Open **Settings** +2. Select **Import Profile** +3. Choose one of the JSON files above + ## Keyboard Shortcuts Coming soon... @@ -178,14 +202,14 @@ src/ **API connection errors:** - Ensure the API server is running on port 5081 - Check firewall settings -- In development, start the API: `cd src/CommandRunner.Api && dotnet run` +- In development, use VS Code launch profile `Command Runner (Electron + API)` or run `cd src/CommandRunner.Api && dotnet run` **Build issues:** -- Ensure .NET 8.0 SDK is installed +- Ensure .NET 10.0 SDK is installed - Clear node_modules: `rm -rf node_modules && npm install` **Permission issues:** -- On Linux: `chmod +x Command-Runner-*.AppImage` +- On Linux: `chmod +x CommandRunner-*.AppImage` - On Windows: Run as administrator if needed ## Contributing diff --git a/examples/profiles/dotnet-local-dev.json b/examples/profiles/dotnet-local-dev.json new file mode 100644 index 0000000..279a866 --- /dev/null +++ b/examples/profiles/dotnet-local-dev.json @@ -0,0 +1,49 @@ +{ + "id": "profile-dotnet-local-dev", + "name": "Dotnet Local Dev", + "description": "Common .NET development commands for local repositories", + "commands": [ + { + "id": "cmd-dotnet-restore", + "name": "Restore", + "executable": "dotnet", + "arguments": "restore", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-dotnet-build", + "name": "Build", + "executable": "dotnet", + "arguments": "build", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-dotnet-test", + "name": "Test", + "executable": "dotnet", + "arguments": "test", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "isFavorite": false +} diff --git a/examples/profiles/javascript-tooling.json b/examples/profiles/javascript-tooling.json new file mode 100644 index 0000000..d4296ee --- /dev/null +++ b/examples/profiles/javascript-tooling.json @@ -0,0 +1,49 @@ +{ + "id": "profile-javascript-tooling", + "name": "JavaScript Tooling", + "description": "Common npm-based commands for frontend repositories", + "commands": [ + { + "id": "cmd-npm-install", + "name": "Install Dependencies", + "executable": "npm", + "arguments": "install", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-npm-lint", + "name": "Lint", + "executable": "npm", + "arguments": "run lint", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-npm-test", + "name": "Test", + "executable": "npm", + "arguments": "test", + "workingDirectory": ".", + "shell": "bash", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "isFavorite": false +} diff --git a/examples/profiles/windows-maintenance.json b/examples/profiles/windows-maintenance.json new file mode 100644 index 0000000..1839e86 --- /dev/null +++ b/examples/profiles/windows-maintenance.json @@ -0,0 +1,49 @@ +{ + "id": "profile-windows-maintenance", + "name": "Windows Maintenance", + "description": "Useful Windows shell commands for diagnostics and cleanup", + "commands": [ + { + "id": "cmd-win-ipconfig", + "name": "Network Configuration", + "executable": "ipconfig", + "arguments": "/all", + "workingDirectory": "C:\\", + "shell": "cmd", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-win-processes", + "name": "List Processes", + "executable": "tasklist", + "arguments": "", + "workingDirectory": "C:\\", + "shell": "cmd", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + { + "id": "cmd-win-disk-check", + "name": "Disk Check (Read-Only)", + "executable": "chkdsk", + "arguments": "C:", + "workingDirectory": "C:\\", + "shell": "cmd", + "environmentVariables": {}, + "iterationEnabled": false, + "requireConfirmation": true, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "isFavorite": false +} diff --git a/src/CommandRunner.Api/CommandRunner.Api.csproj b/src/CommandRunner.Api/CommandRunner.Api.csproj index de874a7..9164648 100644 --- a/src/CommandRunner.Api/CommandRunner.Api.csproj +++ b/src/CommandRunner.Api/CommandRunner.Api.csproj @@ -1,18 +1,13 @@ - net8.0 + net10.0 enable enable - - - - - - + diff --git a/src/CommandRunner.Api/Controllers/CommandsController.cs b/src/CommandRunner.Api/Controllers/CommandsController.cs index 8044657..01d3535 100644 --- a/src/CommandRunner.Api/Controllers/CommandsController.cs +++ b/src/CommandRunner.Api/Controllers/CommandsController.cs @@ -3,6 +3,7 @@ using CommandRunner.Data.Models; using CommandRunner.Business.Services; using CommandRunner.Api.DTOs; +using System.Text.Json; namespace CommandRunner.Api.Controllers; @@ -97,6 +98,108 @@ public async Task> ExecuteCommand(Command } } + [HttpPost("execute-stream")] + public async Task ExecuteCommandStream(CommandExecutionRequest request) + { + Response.Headers.ContentType = "text/event-stream"; + Response.Headers.CacheControl = "no-cache"; + Response.Headers.Append("X-Accel-Buffering", "no"); + + static string EscapeSseData(string data) + { + return data.Replace("\r", "").Replace("\n", "\\n"); + } + + async Task WriteEventAsync(string eventName, string data) + { + await Response.WriteAsync($"event: {eventName}\n"); + await Response.WriteAsync($"data: {EscapeSseData(data)}\n\n"); + await Response.Body.FlushAsync(); + } + + try + { + var profile = await _profileRepository.GetByIdAsync(request.ProfileId); + if (profile == null) + { + await WriteEventAsync("error", "Profile not found"); + return; + } + + var command = profile.Commands.FirstOrDefault(c => c.Id == request.CommandId); + if (command == null) + { + await WriteEventAsync("error", "Command not found in profile"); + return; + } + + var requiresConfirmation = await _securityService.RequiresConfirmationAsync(command); + if (requiresConfirmation && !request.UserConfirmed) + { + await WriteEventAsync("error", "Command requires confirmation before execution"); + return; + } + + var workingDirectory = string.IsNullOrWhiteSpace(request.WorkingDirectory) + ? command.WorkingDirectory + : request.WorkingDirectory; + + var commandToExecute = new Command + { + Id = command.Id, + Name = command.Name, + Executable = command.Executable, + Arguments = command.Arguments, + WorkingDirectory = workingDirectory, + Shell = command.Shell, + EnvironmentVariables = new Dictionary(command.EnvironmentVariables), + IterationEnabled = command.IterationEnabled, + RequireConfirmation = command.RequireConfirmation, + CreatedAt = command.CreatedAt, + UpdatedAt = command.UpdatedAt + }; + + var outputProgress = new Progress(line => + { + WriteEventAsync("stdout", line).GetAwaiter().GetResult(); + }); + + var errorProgress = new Progress(line => + { + WriteEventAsync("stderr", line).GetAwaiter().GetResult(); + }); + + var result = await _executionService.ExecuteCommandWithStreamingAsync( + commandToExecute, + workingDirectory, + outputProgress, + errorProgress, + HttpContext.RequestAborted); + + var response = new CommandExecutionResponse + { + WasSuccessful = result.WasSuccessful, + ExitCode = result.ExitCode, + Output = result.StandardOutput, + ErrorOutput = result.StandardError, + ExecutionTime = result.ExecutionTime, + StartedAt = result.StartedAt, + CompletedAt = result.CompletedAt, + ExecutionErrors = result.ExecutionErrors + }; + + await WriteEventAsync("complete", JsonSerializer.Serialize(response)); + } + catch (OperationCanceledException) + { + await WriteEventAsync("cancelled", "Execution cancelled"); + } + catch (Exception ex) + { + await WriteEventAsync("error", $"Command execution failed: {ex.Message}"); + } + } + [HttpPost("execute-iterative")] public async Task> ExecuteIterativeCommand(CommandExecutionRequest request) { @@ -180,6 +283,299 @@ public async Task> ExecuteIterativeComm } } + [HttpPost("execute-iterative-stream")] + public async Task ExecuteIterativeCommandStream(CommandExecutionRequest request) + { + Response.Headers.ContentType = "text/event-stream"; + Response.Headers.CacheControl = "no-cache"; + Response.Headers.Append("X-Accel-Buffering", "no"); + + static string EscapeSseData(string data) + { + return data.Replace("\r", "").Replace("\n", "\\n"); + } + + async Task WriteEventAsync(string eventName, string data) + { + await Response.WriteAsync($"event: {eventName}\n"); + await Response.WriteAsync($"data: {EscapeSseData(data)}\n\n"); + await Response.Body.FlushAsync(); + } + + try + { + var profile = await _profileRepository.GetByIdAsync(request.ProfileId); + if (profile == null) + { + await WriteEventAsync("error", "Profile not found"); + return; + } + + var command = profile.Commands.FirstOrDefault(c => c.Id == request.CommandId); + if (command == null) + { + await WriteEventAsync("error", "Command not found in profile"); + return; + } + + var requiresConfirmation = await _securityService.RequiresConfirmationAsync(command); + if (requiresConfirmation && !request.UserConfirmed) + { + await WriteEventAsync("error", "Command requires confirmation before execution"); + return; + } + + var options = request.IterationOptions != null + ? new CommandRunner.Business.Services.IterationOptions + { + SkipErrors = request.IterationOptions.SkipErrors, + StopOnFirstFailure = request.IterationOptions.StopOnFirstFailure, + MaxDepth = request.IterationOptions.MaxDepth, + ExcludePatterns = request.IterationOptions.ExcludePatterns, + IncludePatterns = request.IterationOptions.IncludePatterns, + IncludeRootDirectory = request.IterationOptions.IncludeRootDirectory, + MaxParallelism = request.IterationOptions.MaxParallelism + } + : new CommandRunner.Business.Services.IterationOptions(); + + var workingDirectory = string.IsNullOrWhiteSpace(request.WorkingDirectory) + ? command.WorkingDirectory + : request.WorkingDirectory; + + var commandToExecute = new Command + { + Id = command.Id, + Name = command.Name, + Executable = command.Executable, + Arguments = command.Arguments, + WorkingDirectory = workingDirectory, + Shell = command.Shell, + EnvironmentVariables = new Dictionary(command.EnvironmentVariables), + IterationEnabled = command.IterationEnabled, + RequireConfirmation = command.RequireConfirmation, + CreatedAt = command.CreatedAt, + UpdatedAt = command.UpdatedAt + }; + + var startTime = DateTime.UtcNow; + var targets = (await _iterationService.FindIterationTargetsAsync( + workingDirectory, + options, + HttpContext.RequestAborted)).ToList(); + + var iterationProgress = new CommandRunner.Business.Models.IterationProgress + { + CommandId = commandToExecute.Id, + CommandName = commandToExecute.Name, + StartedAt = startTime, + TotalItems = targets.Count + }; + + await WriteEventAsync("progress", JsonSerializer.Serialize(new + { + iterationProgress.TotalItems, + iterationProgress.ProcessedItems, + iterationProgress.SuccessfulItems, + iterationProgress.FailedItems, + iterationProgress.SkippedItems, + iterationProgress.CurrentItem, + iterationProgress.CurrentDirectory, + iterationProgress.IsCompleted, + iterationProgress.WasCancelled, + iterationProgress.StartedAt, + iterationProgress.CompletedAt + })); + + foreach (var target in targets) + { + if (HttpContext.RequestAborted.IsCancellationRequested) + { + iterationProgress.WasCancelled = true; + break; + } + + iterationProgress.CurrentItem = Path.GetFileName(target); + iterationProgress.CurrentDirectory = target; + + await WriteEventAsync("item-start", JsonSerializer.Serialize(new + { + itemPath = target, + itemName = iterationProgress.CurrentItem + })); + + await WriteEventAsync("progress", JsonSerializer.Serialize(new + { + iterationProgress.TotalItems, + iterationProgress.ProcessedItems, + iterationProgress.SuccessfulItems, + iterationProgress.FailedItems, + iterationProgress.SkippedItems, + iterationProgress.CurrentItem, + iterationProgress.CurrentDirectory, + iterationProgress.IsCompleted, + iterationProgress.WasCancelled, + iterationProgress.StartedAt, + iterationProgress.CompletedAt + })); + + var outputProgress = new Progress(line => + { + WriteEventAsync("stdout", JsonSerializer.Serialize(new { itemPath = target, line })).GetAwaiter().GetResult(); + }); + + var errorProgress = new Progress(line => + { + WriteEventAsync("stderr", JsonSerializer.Serialize(new { itemPath = target, line })).GetAwaiter().GetResult(); + }); + + var itemResult = new IterationItemResultDto + { + ItemPath = target, + ExecutedAt = DateTime.UtcNow + }; + + try + { + var result = await _executionService.ExecuteCommandWithStreamingAsync( + commandToExecute, + target, + outputProgress, + errorProgress, + HttpContext.RequestAborted); + + itemResult.WasSuccessful = result.WasSuccessful; + itemResult.Output = result.StandardOutput; + itemResult.ErrorOutput = result.StandardError; + itemResult.ExecutionTime = result.ExecutionTime; + itemResult.ErrorMessage = result.WasSuccessful + ? null + : string.Join(Environment.NewLine, result.ExecutionErrors); + + if (result.WasSuccessful) + { + iterationProgress.SuccessfulItems++; + } + else + { + iterationProgress.FailedItems++; + if (!options.SkipErrors && options.StopOnFirstFailure) + { + iterationProgress.SkippedItems = targets.Count - iterationProgress.ProcessedItems - 1; + iterationProgress.ProcessedItems++; + iterationProgress.ItemResults.Add(new CommandRunner.Business.Models.IterationItemResult + { + ItemPath = itemResult.ItemPath, + WasSuccessful = itemResult.WasSuccessful, + ErrorMessage = itemResult.ErrorMessage ?? string.Empty, + Output = itemResult.Output, + ErrorOutput = itemResult.ErrorOutput, + ExecutionTime = itemResult.ExecutionTime ?? TimeSpan.Zero, + ExecutedAt = itemResult.ExecutedAt + }); + + await WriteEventAsync("item-complete", JsonSerializer.Serialize(itemResult)); + await WriteEventAsync("progress", JsonSerializer.Serialize(new + { + iterationProgress.TotalItems, + iterationProgress.ProcessedItems, + iterationProgress.SuccessfulItems, + iterationProgress.FailedItems, + iterationProgress.SkippedItems, + iterationProgress.CurrentItem, + iterationProgress.CurrentDirectory, + iterationProgress.IsCompleted, + iterationProgress.WasCancelled, + iterationProgress.StartedAt, + iterationProgress.CompletedAt + })); + break; + } + } + } + catch (Exception ex) + { + itemResult.WasSuccessful = false; + itemResult.ErrorMessage = ex.Message; + iterationProgress.FailedItems++; + + if (!options.SkipErrors && options.StopOnFirstFailure) + { + iterationProgress.SkippedItems = targets.Count - iterationProgress.ProcessedItems - 1; + } + } + + iterationProgress.ProcessedItems++; + iterationProgress.ItemResults.Add(new CommandRunner.Business.Models.IterationItemResult + { + ItemPath = itemResult.ItemPath, + WasSuccessful = itemResult.WasSuccessful, + ErrorMessage = itemResult.ErrorMessage ?? string.Empty, + Output = itemResult.Output, + ErrorOutput = itemResult.ErrorOutput, + ExecutionTime = itemResult.ExecutionTime ?? TimeSpan.Zero, + ExecutedAt = itemResult.ExecutedAt + }); + + await WriteEventAsync("item-complete", JsonSerializer.Serialize(itemResult)); + await WriteEventAsync("progress", JsonSerializer.Serialize(new + { + iterationProgress.TotalItems, + iterationProgress.ProcessedItems, + iterationProgress.SuccessfulItems, + iterationProgress.FailedItems, + iterationProgress.SkippedItems, + iterationProgress.CurrentItem, + iterationProgress.CurrentDirectory, + iterationProgress.IsCompleted, + iterationProgress.WasCancelled, + iterationProgress.StartedAt, + iterationProgress.CompletedAt + })); + + if (!itemResult.WasSuccessful && !options.SkipErrors && options.StopOnFirstFailure) + { + break; + } + } + + iterationProgress.IsCompleted = !iterationProgress.WasCancelled && iterationProgress.SkippedItems == 0; + iterationProgress.CompletedAt = DateTime.UtcNow; + + var response = new IterationExecutionResponse + { + IsCompleted = iterationProgress.IsCompleted, + WasCancelled = iterationProgress.WasCancelled, + TotalItems = iterationProgress.TotalItems, + SuccessfulItems = iterationProgress.SuccessfulItems, + FailedItems = iterationProgress.FailedItems, + SkippedItems = iterationProgress.SkippedItems, + ProcessedItems = iterationProgress.ProcessedItems, + StartedAt = iterationProgress.StartedAt, + CompletedAt = iterationProgress.CompletedAt, + ItemResults = iterationProgress.ItemResults.Select(r => new IterationItemResultDto + { + ItemPath = r.ItemPath, + WasSuccessful = r.WasSuccessful, + ErrorMessage = r.ErrorMessage, + Output = r.Output, + ErrorOutput = r.ErrorOutput, + ExecutionTime = r.ExecutionTime, + ExecutedAt = r.ExecutedAt + }).ToList() + }; + + await WriteEventAsync("complete", JsonSerializer.Serialize(response)); + } + catch (OperationCanceledException) + { + await WriteEventAsync("cancelled", "Execution cancelled"); + } + catch (Exception ex) + { + await WriteEventAsync("error", $"Iterative command execution failed: {ex.Message}"); + } + } + [HttpPost("validate/{profileId}/{commandId}")] public async Task> ValidateCommand(string profileId, string commandId) { @@ -206,4 +602,4 @@ public async Task> ExecuteIterativeComm } } -} \ No newline at end of file +} diff --git a/src/CommandRunner.Api/Controllers/ProfilesController.cs b/src/CommandRunner.Api/Controllers/ProfilesController.cs index 2978463..b90246d 100644 --- a/src/CommandRunner.Api/Controllers/ProfilesController.cs +++ b/src/CommandRunner.Api/Controllers/ProfilesController.cs @@ -2,6 +2,7 @@ using CommandRunner.Data.Repositories; using CommandRunner.Data.Models; using CommandRunner.Api.DTOs; +using System.Text.Json; namespace CommandRunner.Api.Controllers; @@ -76,6 +77,76 @@ public async Task> CreateProfile(ProfileDto profileDto) return Created($"/api/profiles/{createdDto.Id}", createdDto); } + [HttpPost("import")] + public async Task> ImportProfile([FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest("Profile file is required"); + } + + await using var stream = file.OpenReadStream(); + var profileDto = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (profileDto == null) + { + return BadRequest("Invalid profile JSON"); + } + + if (string.IsNullOrWhiteSpace(profileDto.Name)) + { + return BadRequest("Profile name is required"); + } + + profileDto.Id = string.IsNullOrWhiteSpace(profileDto.Id) + ? Guid.NewGuid().ToString() + : profileDto.Id; + + profileDto.Commands ??= new List(); + + var now = DateTime.UtcNow; + if (profileDto.CreatedAt == default) + { + profileDto.CreatedAt = now; + } + + profileDto.UpdatedAt = now; + + foreach (var command in profileDto.Commands) + { + command.Id = string.IsNullOrWhiteSpace(command.Id) + ? Guid.NewGuid().ToString() + : command.Id; + + command.Name ??= string.Empty; + command.Executable ??= string.Empty; + command.Arguments ??= string.Empty; + command.WorkingDirectory ??= string.Empty; + command.Shell ??= string.Empty; + command.EnvironmentVariables ??= new Dictionary(); + + if (command.CreatedAt == default) + { + command.CreatedAt = now; + } + + command.UpdatedAt = now; + } + + var existingProfile = await _profileRepository.GetByNameAsync(profileDto.Name); + if (existingProfile != null) + { + return Conflict("A profile with this name already exists"); + } + + var profile = MapFromDto(profileDto); + var createdProfile = await _profileRepository.AddAsync(profile); + return Created($"/api/profiles/{createdProfile.Id}", MapToDto(createdProfile)); + } + [HttpPut("{id}")] public async Task UpdateProfile(string id, ProfileDto profileDto) { @@ -180,4 +251,4 @@ private static Command MapCommandFromDto(CommandDto dto) UpdatedAt = dto.UpdatedAt }; } -} \ No newline at end of file +} diff --git a/src/CommandRunner.Api/Program.cs b/src/CommandRunner.Api/Program.cs index 23e4c79..2cdaa7c 100644 --- a/src/CommandRunner.Api/Program.cs +++ b/src/CommandRunner.Api/Program.cs @@ -12,7 +12,6 @@ options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); }); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); // Register business services builder.Services.AddScoped(); @@ -42,11 +41,6 @@ Console.WriteLine($"AppData: {Environment.GetEnvironmentVariable("APPDATA")}"); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} if (app.Environment.IsDevelopment()) { diff --git a/src/CommandRunner.Business/CommandRunner.Business.csproj b/src/CommandRunner.Business/CommandRunner.Business.csproj index 5e52c11..e1f2f81 100644 --- a/src/CommandRunner.Business/CommandRunner.Business.csproj +++ b/src/CommandRunner.Business/CommandRunner.Business.csproj @@ -1,11 +1,11 @@  - - + + - net8.0 + net10.0 enable enable diff --git a/src/CommandRunner.Business/Services/CommandExecutionService.cs b/src/CommandRunner.Business/Services/CommandExecutionService.cs index fb07120..9a14632 100644 --- a/src/CommandRunner.Business/Services/CommandExecutionService.cs +++ b/src/CommandRunner.Business/Services/CommandExecutionService.cs @@ -102,6 +102,14 @@ public async Task ExecuteCommandAsync( result.ExecutionTime = DateTime.UtcNow - startTime; result.CompletedAt = DateTime.UtcNow; } + catch (OperationCanceledException) + { + result.WasCancelled = true; + result.ExitCode = -1; + result.ExecutionErrors.Add("Execution was cancelled"); + result.ExecutionTime = DateTime.UtcNow - startTime; + result.CompletedAt = DateTime.UtcNow; + } catch (Exception ex) { result.ExitCode = -1; @@ -197,6 +205,14 @@ public async Task ExecuteCommandWithStreamingAsync( result.ExecutionTime = DateTime.UtcNow - startTime; result.CompletedAt = DateTime.UtcNow; } + catch (OperationCanceledException) + { + result.WasCancelled = true; + result.ExitCode = -1; + result.ExecutionErrors.Add("Execution was cancelled"); + result.ExecutionTime = DateTime.UtcNow - startTime; + result.CompletedAt = DateTime.UtcNow; + } catch (Exception ex) { result.ExitCode = -1; @@ -313,4 +329,4 @@ private string GetShellExecutable(string shell) _ => shell }; } -} \ No newline at end of file +} diff --git a/src/CommandRunner.Console/CommandRunner.Console.csproj b/src/CommandRunner.Console/CommandRunner.Console.csproj index 3b178f6..582030c 100644 --- a/src/CommandRunner.Console/CommandRunner.Console.csproj +++ b/src/CommandRunner.Console/CommandRunner.Console.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable @@ -11,4 +11,4 @@ - \ No newline at end of file + diff --git a/src/CommandRunner.Data/CommandRunner.Data.csproj b/src/CommandRunner.Data/CommandRunner.Data.csproj index bb23fb7..6d36c6d 100644 --- a/src/CommandRunner.Data/CommandRunner.Data.csproj +++ b/src/CommandRunner.Data/CommandRunner.Data.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable diff --git a/src/CommandRunner.Data/Models/Command.cs b/src/CommandRunner.Data/Models/Command.cs index 672d915..72f4ece 100644 --- a/src/CommandRunner.Data/Models/Command.cs +++ b/src/CommandRunner.Data/Models/Command.cs @@ -7,10 +7,10 @@ public class Command public string Executable { get; set; } = string.Empty; public string Arguments { get; set; } = string.Empty; public string WorkingDirectory { get; set; } = string.Empty; - public string Shell { get; set; } = "bash"; // bash, cmd, powershell + public string Shell { get; set; } = string.Empty; public Dictionary EnvironmentVariables { get; set; } = new(); public bool IterationEnabled { get; set; } = false; public bool RequireConfirmation { get; set; } = false; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; -} \ No newline at end of file +} diff --git a/src/CommandRunner.ReactWebsite/electron/main.cjs b/src/CommandRunner.ReactWebsite/electron/main.cjs index 672d8b5..2af6503 100644 --- a/src/CommandRunner.ReactWebsite/electron/main.cjs +++ b/src/CommandRunner.ReactWebsite/electron/main.cjs @@ -50,7 +50,7 @@ if (process.platform === 'linux') { function startApiServer() { console.log('Starting API server...'); - const isDev = process.env.NODE_ENV === 'development'; + const isDev = !app.isPackaged; const isPackaged = app.isPackaged; let apiPath; @@ -248,9 +248,9 @@ function createWindow() { }); // Load the app - const isDev = process.env.NODE_ENV === 'development'; + const isDev = !app.isPackaged; if (isDev) { - mainWindow.loadURL('http://localhost:5175'); + mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); @@ -300,4 +300,4 @@ app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } -}); \ No newline at end of file +}); diff --git a/src/CommandRunner.ReactWebsite/package-lock.json b/src/CommandRunner.ReactWebsite/package-lock.json index bd14d24..820d7d8 100644 --- a/src/CommandRunner.ReactWebsite/package-lock.json +++ b/src/CommandRunner.ReactWebsite/package-lock.json @@ -8,13 +8,15 @@ "name": "commandrunner-reactwebsite", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.2", "@mui/lab": "^7.0.0-beta.17", "@mui/material": "^7.3.2", "axios": "^1.11.0", - "electron": "^28.3.3", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -26,6 +28,7 @@ "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", "concurrently": "^8.2.2", + "electron": "^28.3.3", "electron-builder": "^24.13.3", "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -1649,6 +1652,59 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1705,6 +1761,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -1726,6 +1783,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -1740,6 +1798,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -1749,6 +1808,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1758,6 +1818,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -3528,6 +3589,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3553,6 +3615,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" @@ -3620,6 +3683,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", @@ -3659,6 +3723,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3672,6 +3737,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3688,6 +3754,7 @@ "version": "20.19.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz", "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3757,6 +3824,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3788,6 +3856,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4569,6 +4638,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, "license": "MIT", "optional": true }, @@ -4657,6 +4727,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -4740,6 +4811,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.6.0" @@ -4749,6 +4821,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, "license": "MIT", "dependencies": { "clone-response": "^1.0.2", @@ -4929,6 +5002,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" @@ -5338,6 +5412,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -5353,6 +5428,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5382,6 +5458,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5391,7 +5468,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5409,7 +5486,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -5436,6 +5513,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, "license": "MIT", "optional": true }, @@ -5628,6 +5706,7 @@ "version": "28.3.3", "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -5756,6 +5835,7 @@ "version": "18.19.124", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.124.tgz", "integrity": "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -5765,6 +5845,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, "license": "MIT" }, "node_modules/emoji-regex": { @@ -5778,6 +5859,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -5787,6 +5869,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5944,6 +6027,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, "license": "MIT", "optional": true }, @@ -6217,6 +6301,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -6326,6 +6411,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -6681,6 +6767,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -6773,6 +6860,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -6807,7 +6895,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -6857,6 +6945,7 @@ "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", @@ -6882,6 +6971,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -6918,7 +7008,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7034,6 +7124,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { @@ -7055,6 +7146,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -7786,6 +7878,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -7819,6 +7912,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, "license": "ISC", "optional": true }, @@ -7862,6 +7956,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -8062,6 +8157,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8091,6 +8187,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8171,6 +8268,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -8321,6 +8419,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8355,7 +8454,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8386,6 +8485,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8431,6 +8531,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8578,6 +8679,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8688,6 +8790,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8734,6 +8837,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -8775,6 +8879,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -9068,6 +9173,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, "license": "MIT" }, "node_modules/resolve-from": { @@ -9083,6 +9189,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" @@ -9133,6 +9240,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -9335,7 +9443,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9348,6 +9456,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, "license": "MIT", "optional": true }, @@ -9355,6 +9464,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9371,6 +9481,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -9680,6 +9791,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, "license": "BSD-3-Clause", "optional": true }, @@ -9911,6 +10023,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -10209,7 +10322,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -10353,6 +10465,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -11153,6 +11266,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/xmlbuilder": { @@ -11224,6 +11338,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/src/CommandRunner.ReactWebsite/package.json b/src/CommandRunner.ReactWebsite/package.json index 4f6cfc6..42b5a98 100644 --- a/src/CommandRunner.ReactWebsite/package.json +++ b/src/CommandRunner.ReactWebsite/package.json @@ -16,8 +16,16 @@ "files": [ "dist/**/*", "electron/**/*", - "node_modules/**/*", - "dist-electron/api/**/*" + "node_modules/**/*" + ], + "extraResources": [ + { + "from": "dist-electron/api", + "to": "api", + "filter": [ + "**/*" + ] + } ], "mac": { "category": "public.app-category.developer-tools", @@ -52,14 +60,19 @@ "preview": "vite preview", "electron": "electron .", "electron-dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"", - "publish-api": "cd ../CommandRunner.Api && dotnet publish -c Release -r win-x64 --self-contained true -o ../../src/CommandRunner.ReactWebsite/dist-electron/api", - "publish-api-linux": "cd ../CommandRunner.Api && dotnet publish -c Release -r linux-x64 --self-contained true -o ../../src/CommandRunner.ReactWebsite/dist-electron/api", + "publish-api": "cd ../CommandRunner.Api && dotnet publish -c Release -r win-x64 --self-contained true -o ../CommandRunner.ReactWebsite/dist-electron/api", + "publish-api-linux": "cd ../CommandRunner.Api && dotnet publish -c Release -r linux-x64 --self-contained true -o ../CommandRunner.ReactWebsite/dist-electron/api", + "publish-api-mac": "cd ../CommandRunner.Api && dotnet publish -c Release -r osx-x64 --self-contained true -o ../CommandRunner.ReactWebsite/dist-electron/api", "build-electron": "npm run build-electron-build && npm run publish-api && electron-builder", "build-electron-linux": "npm run build-electron-build && npm run publish-api-linux && electron-builder --linux", "build-electron-win": "npm run build-electron-build && npm run publish-api && electron-builder --win", + "build-electron-mac": "npm run build-electron-build && npm run publish-api-mac && electron-builder --mac", "dist": "npm run build-electron" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.2", diff --git a/src/CommandRunner.ReactWebsite/src/App.tsx b/src/CommandRunner.ReactWebsite/src/App.tsx index db7e794..1fa306a 100644 --- a/src/CommandRunner.ReactWebsite/src/App.tsx +++ b/src/CommandRunner.ReactWebsite/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { ThemeProvider, CssBaseline, @@ -42,6 +42,8 @@ function App() { const [focusMode, setFocusMode] = useState(false); const [showSuccessToast, setShowSuccessToast] = useState(false); const [retryingApi, setRetryingApi] = useState(false); + const executionAbortControllerRef = useRef(null); + const lastProgressSnapshotRef = useRef(''); const theme = getTheme(state.theme); @@ -181,6 +183,14 @@ function App() { const handleExecuteCommand = async () => { if (!selectedCommand || !state.selectedProfile) return; + if (executionAbortControllerRef.current) { + executionAbortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + executionAbortControllerRef.current = abortController; + lastProgressSnapshotRef.current = ''; + // Check if command requires confirmation if (selectedCommand.requireConfirmation) { const confirmed = window.confirm( @@ -212,24 +222,57 @@ function App() { let result; if (selectedCommand.iterationEnabled) { - // Use iterative execution for commands with iteration enabled - // Configure for shallow iteration (only immediate children, not recursive) const iterativeRequest = { ...request, iterationOptions: { - maxDepth: 1, // Only immediate children - includeRootDirectory: false, // Don't run in root directory - skipErrors: true, // Continue on errors - stopOnFirstFailure: false, // Process all directories - excludePatterns: [], // No exclusions - includePatterns: [], // Include all - maxParallelism: 3 // Limit concurrent executions + maxDepth: 1, + includeRootDirectory: false, + skipErrors: true, + stopOnFirstFailure: false, + excludePatterns: [], + includePatterns: [], + maxParallelism: 1 } }; - result = await commandsApi.executeIterativeCommand(iterativeRequest); + result = await commandsApi.executeIterativeCommandWithStreaming(iterativeRequest, { + onItemStart: (itemPath) => { + const dirName = itemPath.split(/[\\/]/).filter(Boolean).pop() || itemPath; + setOutput((previous) => `${previous}\n▶ Processing ${dirName}\n`); + }, + onStdout: (itemPath, line) => { + const dirName = itemPath.split(/[\\/]/).filter(Boolean).pop() || itemPath; + setOutput((previous) => `${previous}[${dirName}] ${line}\n`); + }, + onStderr: (itemPath, line) => { + const dirName = itemPath.split(/[\\/]/).filter(Boolean).pop() || itemPath; + setOutput((previous) => `${previous}[${dirName}][stderr] ${line}\n`); + }, + onProgress: (progress) => { + const snapshot = `${progress.processedItems}/${progress.totalItems}:${progress.successfulItems}:${progress.failedItems}:${progress.skippedItems}`; + + if (lastProgressSnapshotRef.current === snapshot) { + return; + } + + lastProgressSnapshotRef.current = snapshot; + setOutput((previous) => `${previous}[progress] ${progress.processedItems}/${progress.totalItems} (ok: ${progress.successfulItems}, failed: ${progress.failedItems}, skipped: ${progress.skippedItems})\n`); + }, + onError: (message) => { + setOutput((previous) => `${previous}[error] ${message}\n`); + } + }, abortController.signal); } else { - // Use regular execution for single commands - result = await commandsApi.executeCommand(request); + result = await commandsApi.executeCommandWithStreaming(request, { + onStdout: (line) => { + setOutput((previous) => `${previous}${line}\n`); + }, + onStderr: (line) => { + setOutput((previous) => `${previous}[stderr] ${line}\n`); + }, + onError: (message) => { + setOutput((previous) => `${previous}[error] ${message}\n`); + } + }, abortController.signal); } let outputText = ''; @@ -285,8 +328,7 @@ function App() { outputText += `⚠️ Execution was cancelled\n`; } } else if (!selectedCommand.iterationEnabled && isCommandResult(result)) { - // Handle regular execution results - outputText = `Command completed\n`; + outputText = `\nCommand completed\n`; outputText += `Exit Code: ${result.exitCode}\n`; outputText += `Execution Time: ${result.executionTime}\n\n`; @@ -300,9 +342,7 @@ function App() { outputText += `⚠️ Warning: Command exited with code ${result.exitCode}\n\n`; } - if (result.output && result.output.trim()) { - outputText += `Output:\n${result.output}\n`; - } else if (result.exitCode === 0) { + if (!result.output?.trim() && result.exitCode === 0) { outputText += `Output: (command completed successfully, no output)\n`; } @@ -316,7 +356,11 @@ function App() { } } - setOutput(outputText); + if (!selectedCommand.iterationEnabled) { + setOutput((previous) => `${previous}${outputText}`); + } else if (outputText) { + setOutput((previous) => `${previous}\n${outputText}`); + } // Set success status for successful command completion if (!selectedCommand.iterationEnabled && isCommandResult(result) && result.exitCode === 0) { @@ -330,12 +374,25 @@ function App() { dispatch({ type: 'ADD_EXECUTION', payload: result as CommandExecutionResponse }); } } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + setOutput((previous) => `${previous}\n[info] Execution stopped by user.\n`); + return; + } + const errorMessage = (error as any)?.message || String(error) || 'Unknown error occurred'; console.error('Command execution error:', error); setOutput(`Command execution failed:\n${errorMessage}\n\nTroubleshooting tips:\n• Check if the working directory exists\n• Verify the command is available in PATH\n• Ensure proper permissions\n• Try using absolute paths instead of ~\n`); // Error status is handled by the error display } finally { setIsExecuting(false); + executionAbortControllerRef.current = null; + lastProgressSnapshotRef.current = ''; + } + }; + + const handleStopExecution = () => { + if (executionAbortControllerRef.current) { + executionAbortControllerRef.current.abort(); } }; @@ -636,6 +693,7 @@ function App() { isExecuting={isExecuting} focusMode={focusMode} onExecute={handleExecuteCommand} + onStop={handleStopExecution} onClear={handleClearCommand} /> @@ -661,7 +719,7 @@ function App() { {/* Output Window */} void; + onStop: () => void; onClear: () => void; } @@ -16,21 +17,35 @@ export const CommandControls: React.FC = ({ isExecuting, focusMode, onExecute, + onStop, onClear }) => { return ( - + {isExecuting ? ( + + ) : ( + + )} ); -}; \ No newline at end of file +}; diff --git a/src/CommandRunner.ReactWebsite/src/components/OutputPanel.tsx b/src/CommandRunner.ReactWebsite/src/components/OutputPanel.tsx index b222cee..86612b0 100644 --- a/src/CommandRunner.ReactWebsite/src/components/OutputPanel.tsx +++ b/src/CommandRunner.ReactWebsite/src/components/OutputPanel.tsx @@ -338,13 +338,14 @@ export const OutputPanel: React.FC = ({ boxShadow: 'inset 0 1px 2px rgba(0, 0, 0, 0.05)', }} > - {isLoading ? ( - + {isLoading && ( + - Loading... + Running... - ) : output ? : 'Ready to execute commands...'} + )} + {output ? : 'Ready to execute commands...'} ); -}; \ No newline at end of file +}; diff --git a/src/CommandRunner.ReactWebsite/src/components/SettingsDialog.tsx b/src/CommandRunner.ReactWebsite/src/components/SettingsDialog.tsx index 7fc9269..194fcfd 100644 --- a/src/CommandRunner.ReactWebsite/src/components/SettingsDialog.tsx +++ b/src/CommandRunner.ReactWebsite/src/components/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, type ChangeEvent, type ReactNode } from 'react'; import { Dialog, DialogTitle, @@ -25,18 +25,65 @@ import { import { Add, Delete, + UploadFile, + DragIndicator, Settings as SettingsIcon, } from '@mui/icons-material'; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { useApp } from '../contexts/AppContext'; -import { ProfileDto, CommandDto } from '../services/api'; +import { ProfileDto, CommandDto, profilesApi } from '../services/api'; interface SettingsDialogProps { open: boolean; onClose: () => void; } +interface SortableCardProps { + id: string; + children: ReactNode; +} + +function SortableCard({ id, children }: SortableCardProps) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); + + return ( + + + + + + + {children} + + ); +} + export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const { state, dispatch } = useApp(); + const sensors = useSensors(useSensor(PointerSensor)); + const profileFileInputRef = useRef(null); const [showProfileForm, setShowProfileForm] = useState(false); const [showCommandForm, setShowCommandForm] = useState(false); const [editingItem, setEditingItem] = useState(null); @@ -53,6 +100,68 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { }); const [isSubmitting, setIsSubmitting] = useState(false); // Local state for immediate feedback + const handleProfilesDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const oldIndex = state.profiles.findIndex((profile) => profile.id === active.id); + const newIndex = state.profiles.findIndex((profile) => profile.id === over.id); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const reorderedProfiles = arrayMove(state.profiles, oldIndex, newIndex); + dispatch({ type: 'SET_PROFILES', payload: reorderedProfiles }); + }; + + const handleCommandsDragEnd = (event: DragEndEvent) => { + if (!state.selectedProfile) { + return; + } + + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const oldIndex = state.selectedProfile.commands.findIndex((command) => command.id === active.id); + const newIndex = state.selectedProfile.commands.findIndex((command) => command.id === over.id); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const reorderedCommands = arrayMove(state.selectedProfile.commands, oldIndex, newIndex); + const updatedProfile = { + ...state.selectedProfile, + commands: reorderedCommands, + updatedAt: new Date().toISOString(), + }; + + dispatch({ type: 'UPDATE_PROFILE', payload: updatedProfile }); + }; + + const handleImportProfile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + try { + const importedProfile = await profilesApi.importProfile(file); + dispatch({ type: 'SET_PROFILES', payload: [...state.profiles, importedProfile] }); + dispatch({ type: 'SET_SELECTED_PROFILE', payload: importedProfile }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to import profile'; + alert(message); + } finally { + event.target.value = ''; + } + }; + // Reset state when dialog opens useEffect(() => { if (open) { @@ -261,89 +370,91 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { Profiles - - - + + + + + + - - {state.profiles.map((profile) => ( - { - // Only select profile if not clicking on buttons - if (!(e.target as HTMLElement).closest('button')) { - handleEditProfile(profile); - } - }} - > - - - - - {profile.name} - - - {profile.description || 'No description'} - - - - {profile.isFavorite && ( - - )} - - - - - - - - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - title="Delete this profile and all its commands" - > - - - - - ))} - + + + + + {profile.name} + + + {profile.description || 'No description'} + + + + {profile.isFavorite && ( + + )} + + + + + + + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + title="Delete this profile and all its commands" + > + + + + + + ))} + + + {state.selectedProfile && ( @@ -367,9 +478,15 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { ) : ( - - {state.selectedProfile.commands.map((command) => ( - + + command.id)} + strategy={verticalListSortingStrategy} + > + + {state.selectedProfile.commands.map((command) => ( + + {command.name} @@ -439,9 +556,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { - - ))} - + + + ))} + + + )} )} @@ -568,6 +688,13 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { + ); -} \ No newline at end of file +} diff --git a/src/CommandRunner.ReactWebsite/src/services/api/commands.ts b/src/CommandRunner.ReactWebsite/src/services/api/commands.ts index 21a110a..538d2c8 100644 --- a/src/CommandRunner.ReactWebsite/src/services/api/commands.ts +++ b/src/CommandRunner.ReactWebsite/src/services/api/commands.ts @@ -6,6 +6,41 @@ import { ValidationResult } from './types'; +type StreamingHandlers = { + onStdout?: (line: string) => void; + onStderr?: (line: string) => void; + onError?: (message: string) => void; +}; + +type IterativeStreamingHandlers = { + onItemStart?: (itemPath: string, itemName: string) => void; + onStdout?: (itemPath: string, line: string) => void; + onStderr?: (itemPath: string, line: string) => void; + onProgress?: (progress: { + totalItems: number; + processedItems: number; + successfulItems: number; + failedItems: number; + skippedItems: number; + currentItem: string; + currentDirectory: string; + isCompleted: boolean; + wasCancelled: boolean; + startedAt: string; + completedAt?: string; + }) => void; + onItemComplete?: (item: { + itemPath: string; + wasSuccessful: boolean; + errorMessage?: string; + output: string; + errorOutput: string; + executionTime?: string; + executedAt: string; + }) => void; + onError?: (message: string) => void; +}; + export class CommandsApiService { // Execute a single command async executeCommand(request: CommandExecutionRequest): Promise { @@ -23,25 +58,216 @@ export class CommandsApiService { } - // Stream execution output (if supported by backend) + // Stream execution output via Server-Sent Events async executeCommandWithStreaming( request: CommandExecutionRequest, - onProgress?: (output: string) => void + handlers?: StreamingHandlers, + signal?: AbortSignal ): Promise { - // For now, use regular execution - // In future, could implement WebSocket or Server-Sent Events for streaming - const response = await this.executeCommand(request); - - if (onProgress && response.output) { - // Simulate streaming by splitting output - const lines = response.output.split('\n'); - lines.forEach((line, index) => { - setTimeout(() => onProgress(line), index * 100); - }); + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5081'; + const response = await fetch(`${apiBaseUrl}/api/commands/execute-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(request), + signal + }); + + if (!response.ok || !response.body) { + throw new Error(`Failed to start streaming command execution (${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let completedResponse: CommandExecutionResponse | null = null; + + const parseEvent = (rawEvent: string) => { + const lines = rawEvent.split('\n'); + let eventName = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + const data = dataLines.join('\n'); + + if (eventName === 'stdout') { + handlers?.onStdout?.(data.replace(/\\n/g, '\n')); + return; + } + + if (eventName === 'stderr') { + handlers?.onStderr?.(data.replace(/\\n/g, '\n')); + return; + } + + if (eventName === 'error' || eventName === 'cancelled') { + handlers?.onError?.(data.replace(/\\n/g, '\n')); + return; + } + + if (eventName === 'complete') { + completedResponse = JSON.parse(data) as CommandExecutionResponse; + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split('\n\n'); + buffer = events.pop() ?? ''; + + for (const rawEvent of events) { + if (rawEvent.trim()) { + parseEvent(rawEvent); + } + } + } + + if (!completedResponse) { + throw new Error('Streaming execution completed without a final response payload'); + } + + return completedResponse; + } + + async executeIterativeCommandWithStreaming( + request: CommandExecutionRequest, + handlers?: IterativeStreamingHandlers, + signal?: AbortSignal + ): Promise { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5081'; + const response = await fetch(`${apiBaseUrl}/api/commands/execute-iterative-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(request), + signal + }); + + if (response.status === 404) { + handlers?.onError?.('Iterative streaming endpoint not found. Falling back to non-streaming iterative execution.'); + return this.executeIterativeCommand(request); + } + + if (!response.ok || !response.body) { + throw new Error(`Failed to start iterative streaming execution (${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let completedResponse: IterationExecutionResponse | null = null; + + const parseEvent = (rawEvent: string) => { + const lines = rawEvent.split('\n'); + let eventName = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + const data = dataLines.join('\n'); + + if (eventName === 'item-start') { + const parsed = JSON.parse(data) as { itemPath: string; itemName: string }; + handlers?.onItemStart?.(parsed.itemPath, parsed.itemName); + return; + } + + if (eventName === 'stdout') { + const parsed = JSON.parse(data) as { itemPath: string; line: string }; + handlers?.onStdout?.(parsed.itemPath, parsed.line); + return; + } + + if (eventName === 'stderr') { + const parsed = JSON.parse(data) as { itemPath: string; line: string }; + handlers?.onStderr?.(parsed.itemPath, parsed.line); + return; + } + + if (eventName === 'progress') { + const parsed = JSON.parse(data) as Record; + handlers?.onProgress?.({ + totalItems: Number(parsed.totalItems ?? parsed.TotalItems ?? 0), + processedItems: Number(parsed.processedItems ?? parsed.ProcessedItems ?? 0), + successfulItems: Number(parsed.successfulItems ?? parsed.SuccessfulItems ?? 0), + failedItems: Number(parsed.failedItems ?? parsed.FailedItems ?? 0), + skippedItems: Number(parsed.skippedItems ?? parsed.SkippedItems ?? 0), + currentItem: String(parsed.currentItem ?? parsed.CurrentItem ?? ''), + currentDirectory: String(parsed.currentDirectory ?? parsed.CurrentDirectory ?? ''), + isCompleted: Boolean(parsed.isCompleted ?? parsed.IsCompleted ?? false), + wasCancelled: Boolean(parsed.wasCancelled ?? parsed.WasCancelled ?? false), + startedAt: String(parsed.startedAt ?? parsed.StartedAt ?? ''), + completedAt: parsed.completedAt ?? parsed.CompletedAt + ? String(parsed.completedAt ?? parsed.CompletedAt) + : undefined, + }); + return; + } + + if (eventName === 'item-complete') { + handlers?.onItemComplete?.(JSON.parse(data)); + return; + } + + if (eventName === 'error' || eventName === 'cancelled') { + handlers?.onError?.(data.replace(/\\n/g, '\n')); + return; + } + + if (eventName === 'complete') { + completedResponse = JSON.parse(data) as IterationExecutionResponse; + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split('\n\n'); + buffer = events.pop() ?? ''; + + for (const rawEvent of events) { + if (rawEvent.trim()) { + parseEvent(rawEvent); + } + } + } + + if (!completedResponse) { + throw new Error('Iterative streaming execution completed without a final response payload'); } - return response; + return completedResponse; } } -export const commandsApi = new CommandsApiService(); \ No newline at end of file +export const commandsApi = new CommandsApiService(); diff --git a/src/CommandRunner.ReactWebsite/src/services/api/profiles.ts b/src/CommandRunner.ReactWebsite/src/services/api/profiles.ts index 96014f6..0e392fc 100644 --- a/src/CommandRunner.ReactWebsite/src/services/api/profiles.ts +++ b/src/CommandRunner.ReactWebsite/src/services/api/profiles.ts @@ -27,6 +27,25 @@ export class ProfilesApiService { return apiClient.post('/api/profiles', profile); } + // Import profile from JSON file + async importProfile(file: File): Promise { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5081'; + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${apiBaseUrl}/api/profiles/import`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Failed to import profile (${response.status})`); + } + + return response.json() as Promise; + } + // Update profile async updateProfile(id: string, profile: Partial): Promise { return apiClient.put(`/api/profiles/${id}`, profile); @@ -76,4 +95,4 @@ export class ProfilesApiService { } } -export const profilesApi = new ProfilesApiService(); \ No newline at end of file +export const profilesApi = new ProfilesApiService(); diff --git a/test/CommandRunner.UnitTests/CommandExecutionServiceTests.cs b/test/CommandRunner.UnitTests/CommandExecutionServiceTests.cs index 75f029b..acd7a4e 100644 --- a/test/CommandRunner.UnitTests/CommandExecutionServiceTests.cs +++ b/test/CommandRunner.UnitTests/CommandExecutionServiceTests.cs @@ -1,10 +1,10 @@ using CommandRunner.Business.Services; using CommandRunner.Business.Models; using CommandRunner.Data.Models; +using System.Collections.Concurrent; namespace CommandRunner.UnitTests; -[Ignore("Ignoring so the tests pass on other platforms.")] [TestFixture] public class CommandExecutionServiceTests { @@ -24,8 +24,9 @@ public async Task ExecuteCommandAsync_ValidEchoCommand_ReturnsSuccess() var command = new Command { Name = "Echo Test", - Executable = "echo", + Executable = OperatingSystem.IsWindows() ? "echo" : "echo", Arguments = "Hello World", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory() }; @@ -41,8 +42,8 @@ public async Task ExecuteCommandAsync_ValidEchoCommand_ReturnsSuccess() Assert.That(result.WorkingDirectory, Is.EqualTo(command.WorkingDirectory)); Assert.That(result.CommandId, Is.EqualTo(command.Id)); Assert.That(result.CommandName, Is.EqualTo(command.Name)); - Assert.That(result.StartedAt, Is.Not.EqualTo(default)); - Assert.That(result.CompletedAt, Is.Not.EqualTo(default)); + Assert.That(result.StartedAt, Is.Not.EqualTo(default(DateTime))); + Assert.That(result.CompletedAt, Is.Not.EqualTo(default(DateTime))); Assert.That(result.ExecutionTime > TimeSpan.Zero, Is.True); }); } @@ -77,6 +78,7 @@ public async Task ExecuteCommandAsync_CommandWithEnvironmentVariables_InheritsVa Name = "Env Test", Executable = "echo", Arguments = "test", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory(), EnvironmentVariables = new Dictionary { @@ -101,7 +103,7 @@ public async Task ExecuteCommandAsync_CommandWithCancellation_HandlesCancellatio { Name = "Long Running", Executable = OperatingSystem.IsWindows() ? "ping" : "sleep", - Arguments = OperatingSystem.IsWindows() ? "-t 10 127.0.0.1" : "10", + Arguments = OperatingSystem.IsWindows() ? "-n 10 127.0.0.1" : "10", WorkingDirectory = Directory.GetCurrentDirectory() }; using var cts = new CancellationTokenSource(); @@ -126,6 +128,7 @@ public async Task ExecuteCommandsAsync_MultipleCommands_ExecutesSequentially() Name = "Command 1", Executable = "echo", Arguments = "First", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory() }, new Command @@ -133,6 +136,7 @@ public async Task ExecuteCommandsAsync_MultipleCommands_ExecutesSequentially() Name = "Command 2", Executable = "echo", Arguments = "Second", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory() } }; @@ -163,6 +167,7 @@ public async Task ExecuteCommandsParallelAsync_MultipleCommands_ExecutesInParall Name = "Parallel 1", Executable = "echo", Arguments = "Parallel1", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory() }, new Command @@ -170,10 +175,11 @@ public async Task ExecuteCommandsParallelAsync_MultipleCommands_ExecutesInParall Name = "Parallel 2", Executable = "echo", Arguments = "Parallel2", + Shell = OperatingSystem.IsWindows() ? "cmd" : "bash", WorkingDirectory = Directory.GetCurrentDirectory() } }; - var results = new List(); + var results = new ConcurrentBag(); var executionResults = await _executionService.ExecuteCommandsParallelAsync( commands, @@ -188,4 +194,4 @@ public async Task ExecuteCommandsParallelAsync_MultipleCommands_ExecutesInParall Assert.That(results.All(r => r.WasSuccessful), Is.True); }); } -} \ No newline at end of file +} diff --git a/test/CommandRunner.UnitTests/CommandRunner.UnitTests.csproj b/test/CommandRunner.UnitTests/CommandRunner.UnitTests.csproj index 31d95cf..2fa0cb5 100644 --- a/test/CommandRunner.UnitTests/CommandRunner.UnitTests.csproj +++ b/test/CommandRunner.UnitTests/CommandRunner.UnitTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -10,19 +10,19 @@ - - - - - + + + + + - - + + diff --git a/test/CommandRunner.UnitTests/CommandValidationServiceTests.cs b/test/CommandRunner.UnitTests/CommandValidationServiceTests.cs index f3b8c6a..95a8c0f 100644 --- a/test/CommandRunner.UnitTests/CommandValidationServiceTests.cs +++ b/test/CommandRunner.UnitTests/CommandValidationServiceTests.cs @@ -29,8 +29,8 @@ public async Task ValidateCommandAsync_ValidCommand_ReturnsSuccess() var result = await _validationService.ValidateCommandAsync(command, command.WorkingDirectory); - Assert.IsTrue(result.IsValid); - Assert.IsEmpty(result.Errors); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); } [Test] @@ -48,8 +48,8 @@ public async Task ValidateCommandAsync_CommandWithoutName_ReturnsError() Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains("Command name is required", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Command name is required")); }); } @@ -68,8 +68,8 @@ public async Task ValidateCommandAsync_CommandWithoutExecutable_ReturnsError() Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains("Executable is required", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Executable is required")); }); } @@ -86,8 +86,8 @@ public async Task ValidateCommandAsync_InvalidWorkingDirectory_ReturnsError() var result = await _validationService.ValidateCommandAsync(command, invalidDirectory); - Assert.IsFalse(result.IsValid); - Assert.Contains($"Working directory does not exist: {invalidDirectory}", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain($"Working directory does not exist: {invalidDirectory}")); } [Test] @@ -105,8 +105,8 @@ public async Task ValidateCommandAsync_CommandWithManyEnvironmentVariables_AddsW var result = await _validationService.ValidateCommandAsync(command, command.WorkingDirectory); - Assert.IsTrue(result.IsValid); - Assert.Contains("Large number of environment variables may impact performance", result.Warnings); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Warnings, Does.Contain("Large number of environment variables may impact performance")); } [Test] @@ -123,8 +123,8 @@ public async Task ValidateCommandAsync_IterationEnabledWithoutArguments_AddsWarn var result = await _validationService.ValidateCommandAsync(command, command.WorkingDirectory); - Assert.IsTrue(result.IsValid); - Assert.Contains("Iteration enabled but no arguments provided - command will run with default parameters", result.Warnings); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Warnings, Does.Contain("Iteration enabled but no arguments provided - command will run with default parameters")); } [Test] @@ -134,8 +134,8 @@ public async Task ValidateWorkingDirectoryAsync_ValidDirectory_ReturnsSuccess() var result = await _validationService.ValidateWorkingDirectoryAsync(validDirectory); - Assert.IsTrue(result.IsValid); - Assert.IsEmpty(result.Errors); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); } [Test] @@ -145,8 +145,8 @@ public async Task ValidateWorkingDirectoryAsync_NonexistentDirectory_ReturnsErro var result = await _validationService.ValidateWorkingDirectoryAsync(invalidDirectory); - Assert.IsFalse(result.IsValid); - Assert.Contains($"Working directory does not exist: {invalidDirectory}", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain($"Working directory does not exist: {invalidDirectory}")); } [Test] @@ -154,8 +154,8 @@ public async Task ValidateWorkingDirectoryAsync_EmptyDirectory_ReturnsError() { var result = await _validationService.ValidateWorkingDirectoryAsync(""); - Assert.IsFalse(result.IsValid); - Assert.Contains("Working directory is required", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Working directory is required")); } [Test] @@ -165,8 +165,8 @@ public async Task ValidateExecutableAsync_ValidExecutableInPath_ReturnsSuccess() var result = await _validationService.ValidateExecutableAsync(executable, Directory.GetCurrentDirectory()); - Assert.IsTrue(result.IsValid); - Assert.IsEmpty(result.Errors); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); } [Test] @@ -176,8 +176,8 @@ public async Task ValidateExecutableAsync_NonexistentExecutable_ReturnsError() var result = await _validationService.ValidateExecutableAsync(nonexistentExecutable, Directory.GetCurrentDirectory()); - Assert.IsFalse(result.IsValid); - Assert.Contains($"Executable not found in PATH: {nonexistentExecutable}", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain($"Executable not found in PATH: {nonexistentExecutable}")); } [Test] @@ -192,8 +192,8 @@ public async Task ValidateEnvironmentVariablesAsync_ValidVariables_ReturnsSucces var result = await _validationService.ValidateEnvironmentVariablesAsync(envVars); - Assert.IsTrue(result.IsValid); - Assert.IsEmpty(result.Errors); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); } [Test] @@ -206,8 +206,8 @@ public async Task ValidateEnvironmentVariablesAsync_InvalidVariableName_ReturnsE var result = await _validationService.ValidateEnvironmentVariablesAsync(envVars); - Assert.IsFalse(result.IsValid); - Assert.Contains("Invalid environment variable name: INVALID-NAME", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Invalid environment variable name: INVALID-NAME")); } [Test] @@ -220,8 +220,8 @@ public async Task ValidateEnvironmentVariablesAsync_EmptyVariableName_ReturnsErr var result = await _validationService.ValidateEnvironmentVariablesAsync(envVars); - Assert.IsFalse(result.IsValid); - Assert.Contains("Environment variable key cannot be empty", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Environment variable key cannot be empty")); } [Test] @@ -235,7 +235,7 @@ public async Task ValidateEnvironmentVariablesAsync_VeryLongValue_AddsWarning() var result = await _validationService.ValidateEnvironmentVariablesAsync(envVars); - Assert.IsTrue(result.IsValid); - Assert.Contains("Environment variable 'LONG_VAR' has a very long value (10001 characters)", result.Warnings); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Warnings, Does.Contain("Environment variable 'LONG_VAR' has a very long value (10001 characters)")); } -} \ No newline at end of file +} diff --git a/test/CommandRunner.UnitTests/IterationServiceTests.cs b/test/CommandRunner.UnitTests/IterationServiceTests.cs index 1167600..f619b0d 100644 --- a/test/CommandRunner.UnitTests/IterationServiceTests.cs +++ b/test/CommandRunner.UnitTests/IterationServiceTests.cs @@ -20,7 +20,6 @@ public void Setup() _iterationService = new IterationService(_executionService); } - [Ignore("Windows failing")] [Test] public async Task ExecuteIterativeAsync_SingleDirectory_ExecutesCommand() { @@ -53,12 +52,12 @@ public async Task ExecuteIterativeAsync_SingleDirectory_ExecutesCommand() Assert.Multiple(() => { - Assert.IsTrue(result.IsCompleted); + Assert.That(result.IsCompleted, Is.True); Assert.That(result.TotalItems, Is.EqualTo(1)); Assert.That(result.SuccessfulItems, Is.EqualTo(1)); Assert.That(result.FailedItems, Is.EqualTo(0)); - Assert.IsTrue(progressReports.Any()); - Assert.IsTrue(progressReports.Last().IsCompleted); + Assert.That(progressReports.Any(), Is.True); + Assert.That(progressReports.Last().IsCompleted, Is.True); }); } finally @@ -67,7 +66,6 @@ public async Task ExecuteIterativeAsync_SingleDirectory_ExecutesCommand() } } - [Ignore("Windows failing")] [Test] public async Task ExecuteIterativeAsync_MultipleSubdirectories_ExecutesInAll() { @@ -102,7 +100,7 @@ public async Task ExecuteIterativeAsync_MultipleSubdirectories_ExecutesInAll() Assert.Multiple(() => { - Assert.IsTrue(result.IsCompleted); + Assert.That(result.IsCompleted, Is.True); Assert.That(result.TotalItems, Is.EqualTo(2)); Assert.That(result.SuccessfulItems, Is.EqualTo(2)); Assert.That(result.FailedItems, Is.EqualTo(0)); @@ -158,12 +156,12 @@ public async Task ExecuteIterativeAsync_WithCancellation_StopsExecution() // Either it was cancelled, or it completed but we can see progress was made if (result.WasCancelled) { - Assert.IsFalse(result.IsCompleted); + Assert.That(result.IsCompleted, Is.False); } else { // If not cancelled, at least some progress should have been made - Assert.IsTrue(progressReports.Count > 0); + Assert.That(progressReports.Count > 0, Is.True); } }); } @@ -208,7 +206,7 @@ public async Task ExecuteIterativeAsync_SkipErrorsOnFailure_ContinuesExecution() Assert.Multiple(() => { - Assert.IsTrue(result.IsCompleted); + Assert.That(result.IsCompleted, Is.True); Assert.That(result.TotalItems, Is.EqualTo(2)); Assert.That(result.SuccessfulItems, Is.EqualTo(0)); Assert.That(result.FailedItems, Is.EqualTo(2)); @@ -257,7 +255,7 @@ public async Task ExecuteIterativeAsync_StopOnFirstFailure_StopsAfterError() Assert.Multiple(() => { - Assert.IsFalse(result.IsCompleted); + Assert.That(result.IsCompleted, Is.False); Assert.That(result.FailedItems, Is.EqualTo(1)); Assert.That(result.SkippedItems, Is.EqualTo(1)); }); @@ -294,8 +292,8 @@ public async Task FindIterationTargetsAsync_ValidDirectory_ReturnsDirectories() Assert.Multiple(() => { Assert.That(targets.Count(), Is.EqualTo(2)); - Assert.Contains(subDir1, targets.ToList()); - Assert.Contains(subDir2, targets.ToList()); + Assert.That(targets, Does.Contain(subDir1)); + Assert.That(targets, Does.Contain(subDir2)); }); } finally @@ -307,7 +305,9 @@ public async Task FindIterationTargetsAsync_ValidDirectory_ReturnsDirectories() [Test] public async Task ValidateIterationOptionsAsync_InvalidRootDirectory_ReturnsError() { - var invalidPath = "/definitely/does/not/exist"; + var invalidPath = OperatingSystem.IsWindows() + ? @"Z:\definitely\does\not\exist" + : "/definitely/does/not/exist"; var options = new IterationOptions(); var result = await _iterationService.ValidateIterationOptionsAsync( @@ -316,8 +316,8 @@ public async Task ValidateIterationOptionsAsync_InvalidRootDirectory_ReturnsErro Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains($"Root directory does not exist: {invalidPath}", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain($"Root directory does not exist: {invalidPath}")); }); } -} \ No newline at end of file +} diff --git a/test/CommandRunner.UnitTests/SecurityServiceTests.cs b/test/CommandRunner.UnitTests/SecurityServiceTests.cs index 8089d0e..e42ac37 100644 --- a/test/CommandRunner.UnitTests/SecurityServiceTests.cs +++ b/test/CommandRunner.UnitTests/SecurityServiceTests.cs @@ -29,7 +29,7 @@ public async Task RequiresConfirmationAsync_DangerousCommand_ReturnsTrue() var result = await _securityService.RequiresConfirmationAsync(command); - Assert.IsTrue(result); + Assert.That(result, Is.True); } [Test] @@ -45,7 +45,7 @@ public async Task RequiresConfirmationAsync_SafeCommand_ReturnsFalse() var result = await _securityService.RequiresConfirmationAsync(command); - Assert.IsFalse(result); + Assert.That(result, Is.False); } [Test] @@ -62,7 +62,7 @@ public async Task RequiresConfirmationAsync_CommandWithRequireConfirmationFlag_R var result = await _securityService.RequiresConfirmationAsync(command); - Assert.IsTrue(result); + Assert.That(result, Is.True); } [Test] @@ -80,8 +80,8 @@ public async Task ValidateCommandSecurityAsync_BlockedCommand_ReturnsError() Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains("Command 'blockedcommand' is blocked for security reasons", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Command 'blockedcommand' is blocked for security reasons")); }); } @@ -100,8 +100,8 @@ public async Task ValidateCommandSecurityAsync_ValidCommand_ReturnsSuccess() Assert.Multiple(() => { - Assert.IsTrue(result.IsValid); - Assert.IsEmpty(result.Errors); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); }); } @@ -112,13 +112,13 @@ public async Task GetSecuritySettingsAsync_ReturnsDefaultSettings() Assert.Multiple(() => { - Assert.IsNotNull(settings); - Assert.IsTrue(settings.RequireConfirmationForDangerousCommands); - Assert.IsTrue(settings.SandboxExecution); + Assert.That(settings, Is.Not.Null); + Assert.That(settings.RequireConfirmationForDangerousCommands, Is.True); + Assert.That(settings.SandboxExecution, Is.True); Assert.That(settings.MaxExecutionTimeSeconds, Is.EqualTo(300)); - Assert.IsNotNull(settings.BlockedCommands); - Assert.IsNotNull(settings.DangerousPatterns); - Assert.IsTrue(settings.LogCommandExecutions); + Assert.That(settings.BlockedCommands, Is.Not.Null); + Assert.That(settings.DangerousPatterns, Is.Not.Null); + Assert.That(settings.LogCommandExecutions, Is.True); }); } @@ -142,12 +142,12 @@ public async Task UpdateSecuritySettingsAsync_ValidSettings_UpdatesSuccessfully( Assert.Multiple(() => { - Assert.That(retrievedSettings.RequireConfirmationForDangerousCommands, Is.EqualTo(false)); - Assert.That(retrievedSettings.SandboxExecution, Is.EqualTo(false)); + Assert.That(retrievedSettings.RequireConfirmationForDangerousCommands, Is.False); + Assert.That(retrievedSettings.SandboxExecution, Is.False); Assert.That(retrievedSettings.MaxExecutionTimeSeconds, Is.EqualTo(600)); - Assert.Contains("testblock", retrievedSettings.BlockedCommands); - Assert.Contains("testpattern", retrievedSettings.DangerousPatterns); - Assert.That(retrievedSettings.LogCommandExecutions, Is.EqualTo(false)); + Assert.That(retrievedSettings.BlockedCommands, Does.Contain("testblock")); + Assert.That(retrievedSettings.DangerousPatterns, Does.Contain("testpattern")); + Assert.That(retrievedSettings.LogCommandExecutions, Is.False); Assert.That(retrievedSettings.LogDirectory, Is.EqualTo("/var/log")); }); } @@ -165,7 +165,7 @@ public async Task IsDangerousCommandAsync_CommandWithDangerousPattern_ReturnsTru var result = await _securityService.IsDangerousCommandAsync(command); - Assert.IsTrue(result); + Assert.That(result, Is.True); } [Test] @@ -181,7 +181,7 @@ public async Task IsDangerousCommandAsync_CommandWithFormatPattern_ReturnsTrue() var result = await _securityService.IsDangerousCommandAsync(command); - Assert.IsTrue(result); + Assert.That(result, Is.True); } [Test] @@ -197,7 +197,7 @@ public async Task IsDangerousCommandAsync_SafeCommand_ReturnsFalse() var result = await _securityService.IsDangerousCommandAsync(command); - Assert.IsFalse(result); + Assert.That(result, Is.False); } [Test] @@ -209,7 +209,7 @@ public async Task SanitizeArgumentsAsync_ArgumentsWithInjection_ReturnsSanitized Assert.Multiple(() => { - Assert.IsNotNull(result); + Assert.That(result, Is.Not.Null); Assert.That(result, Is.Not.EqualTo(dangerousArgs)); Assert.That(result, Does.Not.Contain(";")); }); @@ -234,7 +234,7 @@ public async Task SanitizeArgumentsAsync_ArgumentsWithPathTraversal_ReturnsSanit Assert.Multiple(() => { - Assert.IsNotNull(result); + Assert.That(result, Is.Not.Null); Assert.That(result, Does.Not.Contain("../")); }); } @@ -254,8 +254,8 @@ public async Task ValidateCommandSecurityAsync_CommandWithInjection_ReturnsError Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains("Command arguments contain potentially dangerous characters", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Command arguments contain potentially dangerous characters")); }); } @@ -274,8 +274,8 @@ public async Task ValidateCommandSecurityAsync_CommandWithPathTraversal_ReturnsE Assert.Multiple(() => { - Assert.IsFalse(result.IsValid); - Assert.Contains("Command arguments contain path traversal patterns", result.Errors); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors, Does.Contain("Command arguments contain path traversal patterns")); }); } -} \ No newline at end of file +}