diff --git a/.cursor/rules/developer-cli/developer-cli.mdc b/.cursor/rules/developer-cli/developer-cli.mdc index c2bd5a8107..8c441da00e 100644 --- a/.cursor/rules/developer-cli/developer-cli.mdc +++ b/.cursor/rules/developer-cli/developer-cli.mdc @@ -20,9 +20,9 @@ Carefully follow these instructions when implementing and extending the custom D 2. Command Options: - Use double-dash (`--`) for long option names and single-dash (`-`) for abbreviations. - Provide clear, concise descriptions for all options. - - Use consistent naming across commands for similar options (e.g., `--solution-name` and `-s`). + - Use consistent naming across commands for similar options (e.g., `--self-contained-system` and `-s`). - Define option types explicitly (e.g., `Option`, `Option`). - - For positional arguments, include both positional and named options (e.g., `["", "--solution-name", "-s"]`). + - For positional arguments, include both positional and named options (e.g., `["", "--self-contained-system", "-s"]`). - Set default values where appropriate using lambda expressions. 3. Prerequisites and Dependencies: @@ -82,7 +82,7 @@ public class BuildCommand : Command { public BuildCommand() : base("build", "Builds the solution") { - AddOption(new Option(["", "--solution-name", "-s"], "The solution to build")); // ✅ DO: Consistent option naming + AddOption(new Option(["", "--self-contained-system", "-s"], "The self-contained system to build")); // ✅ DO: Consistent option naming AddOption(new Option(["--verbose", "-v"], () => false, "Enable verbose output")); Handler = CommandHandler.Create(Execute); } diff --git a/.cursor/rules/tools.mdc b/.cursor/rules/tools.mdc index 8135674e3c..4bfaccfbd0 100644 --- a/.cursor/rules/tools.mdc +++ b/.cursor/rules/tools.mdc @@ -50,8 +50,8 @@ Use these commands continously when you are working on the codebase. # Build only backend [CLI_ALIAS] build --backend -# Build specific backend solution -[CLI_ALIAS] build --backend --solution-name +# Build specific self-contained system backend +[CLI_ALIAS] build --backend --self-contained-system # Build only frontend [CLI_ALIAS] build --frontend @@ -65,8 +65,8 @@ After you have completed a backend task and want to ensure that it works as expe # Run all tests [CLI_ALIAS] test -# Run tests for specific solution -[CLI_ALIAS] test --solution-name +# Run tests for specific self-contained system +[CLI_ALIAS] test --self-contained-system ``` ## End-to-End Test Commands @@ -103,8 +103,8 @@ Run these commands before you commit your changes. # Format only backend (run this before commit) [CLI_ALIAS] format --backend -# Format specific backend solution (run this before commit) -[CLI_ALIAS] format --backend --solution-name +# Format specific self-contained system backend (run this before commit) +[CLI_ALIAS] format --backend --self-contained-system # Format only frontend (run this before commit) [CLI_ALIAS] format --frontend @@ -112,12 +112,12 @@ Run these commands before you commit your changes. ## Command Breakdown -Using `--solution-name` with backend commands is recommended as it significantly reduces execution time compared to running commands against the entire codebase. Especially for the `format` and `inspect` commands. +Using `--self-contained-system` (or `-s`) with backend commands is recommended as it significantly reduces execution time compared to running commands against the entire codebase. Especially for the `format` and `inspect` commands. -- `[CLI_ALIAS] inspect --backend --solution-name BackOffice.slnf` -- `[CLI_ALIAS] format --backend --solution-name AccountManagement.slnf` +- `[CLI_ALIAS] inspect --backend --self-contained-system back-office` +- `[CLI_ALIAS] format --backend --self-contained-system account-management` -The value of the `--solution-name` parameter should be the solution filter file (`.slnf`) name from the self-contained system directory. +The value of the `--self-contained-system` parameter should be the kebab-case name of the self-contained system directory (e.g., `account-management`, `back-office`). ## Troubleshooting when `[CLI_ALIAS]` fails diff --git a/.cursor/rules/workflows/prepare-pull-request.mdc b/.cursor/rules/workflows/prepare-pull-request.mdc index 6b1d4b1793..7cc4c12436 100644 --- a/.cursor/rules/workflows/prepare-pull-request.mdc +++ b/.cursor/rules/workflows/prepare-pull-request.mdc @@ -51,7 +51,7 @@ Use this workflow to create pull request titles and descriptions: 6. Build, test, format and inspect the codebase: - If changes have been made to backend `*.cs` but only to one self-contained system, run: ```bash - [CLI_ALIAS] check --backend --solution-name SelfContainedSystem.slnf + [CLI_ALIAS] check --backend --self-contained-system ``` - If backend changes have been made to `*.cs` in multiple self-contained systems or the Shared Kernel, run: ```bash diff --git a/.github/workflows/account-management.yml b/.github/workflows/account-management.yml index 1250e3e830..649dbbdd90 100644 --- a/.github/workflows/account-management.yml +++ b/.github/workflows/account-management.yml @@ -191,7 +191,7 @@ jobs: - name: Run Code Inspections working-directory: developer-cli run: | - dotnet run inspect --backend --solution-name AccountManagement.slnf | tee inspection-output.log + dotnet run inspect --backend --self-contained-system account-management | tee inspection-output.log if ! grep -q "No backend issues found!" inspection-output.log; then echo "Code inspection issues found." @@ -201,11 +201,11 @@ jobs: - name: Check for Code Formatting Issues working-directory: developer-cli run: | - dotnet run format --backend --solution-name AccountManagement.slnf + dotnet run format --backend --self-contained-system account-management # Check for any changes made by the code formatter git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run format --backend --solution-name AccountManagement.slnf' from /developer-cli folder locally and commit the formatted code." + echo "Formatting issues detected. Please run 'dotnet run format --backend --self-contained-system account-management' from /developer-cli folder locally and commit the formatted code." exit 1 } diff --git a/.github/workflows/back-office.yml b/.github/workflows/back-office.yml index 5c7ea36f8a..18749ad3a3 100644 --- a/.github/workflows/back-office.yml +++ b/.github/workflows/back-office.yml @@ -191,7 +191,7 @@ jobs: - name: Run Code Inspections working-directory: developer-cli run: | - dotnet run inspect --backend --solution-name BackOffice.slnf | tee inspection-output.log + dotnet run inspect --backend --self-contained-system back-office | tee inspection-output.log if ! grep -q "No backend issues found!" inspection-output.log; then echo "Code inspection issues found." @@ -201,11 +201,11 @@ jobs: - name: Check for Code Formatting Issues working-directory: developer-cli run: | - dotnet run format --backend --solution-name BackOffice.slnf + dotnet run format --backend --self-contained-system back-office # Check for any changes made by the code formatter git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run format --backend --solution-name BackOffice.slnf' from /developer-cli folder locally and commit the formatted code." + echo "Formatting issues detected. Please run 'dotnet run format --backend --self-contained-system back-office' from /developer-cli folder locally and commit the formatted code." exit 1 } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..5ccf692f24 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "developer-cli": { + "command": "dotnet", + "args": ["run", "--project", "developer-cli", "mcp"] + } + } +} \ No newline at end of file diff --git a/.windsurf/rules/developer-cli/developer-cli.md b/.windsurf/rules/developer-cli/developer-cli.md index d9380e3109..03cf62bc37 100644 --- a/.windsurf/rules/developer-cli/developer-cli.md +++ b/.windsurf/rules/developer-cli/developer-cli.md @@ -21,9 +21,9 @@ Carefully follow these instructions when implementing and extending the custom D 2. Command Options: - Use double-dash (`--`) for long option names and single-dash (`-`) for abbreviations. - Provide clear, concise descriptions for all options. - - Use consistent naming across commands for similar options (e.g., `--solution-name` and `-s`). + - Use consistent naming across commands for similar options (e.g., `--self-contained-system` and `-s`). - Define option types explicitly (e.g., `Option`, `Option`). - - For positional arguments, include both positional and named options (e.g., `["", "--solution-name", "-s"]`). + - For positional arguments, include both positional and named options (e.g., `["", "--self-contained-system", "-s"]`). - Set default values where appropriate using lambda expressions. 3. Prerequisites and Dependencies: @@ -83,7 +83,7 @@ public class BuildCommand : Command { public BuildCommand() : base("build", "Builds the solution") { - AddOption(new Option(["", "--solution-name", "-s"], "The solution to build")); // ✅ DO: Consistent option naming + AddOption(new Option(["", "--self-contained-system", "-s"], "The self-contained system to build")); // ✅ DO: Consistent option naming AddOption(new Option(["--verbose", "-v"], () => false, "Enable verbose output")); Handler = CommandHandler.Create(Execute); } diff --git a/.windsurf/rules/tools.md b/.windsurf/rules/tools.md index 87db2f21d6..16290d3cdd 100644 --- a/.windsurf/rules/tools.md +++ b/.windsurf/rules/tools.md @@ -50,8 +50,8 @@ Use these commands continously when you are working on the codebase. # Build only backend [CLI_ALIAS] build --backend -# Build specific backend solution -[CLI_ALIAS] build --backend --solution-name +# Build specific self-contained system backend +[CLI_ALIAS] build --backend --self-contained-system # Build only frontend [CLI_ALIAS] build --frontend @@ -65,8 +65,8 @@ After you have completed a backend task and want to ensure that it works as expe # Run all tests [CLI_ALIAS] test -# Run tests for specific solution -[CLI_ALIAS] test --solution-name +# Run tests for specific self-contained system +[CLI_ALIAS] test --self-contained-system ``` ## End-to-End Test Commands @@ -103,8 +103,8 @@ Run these commands before you commit your changes. # Format only backend (run this before commit) [CLI_ALIAS] format --backend -# Format specific backend solution (run this before commit) -[CLI_ALIAS] format --backend --solution-name +# Format specific self-contained system backend (run this before commit) +[CLI_ALIAS] format --backend --self-contained-system # Format only frontend (run this before commit) [CLI_ALIAS] format --frontend @@ -112,12 +112,12 @@ Run these commands before you commit your changes. ## Command Breakdown -Using `--solution-name` with backend commands is recommended as it significantly reduces execution time compared to running commands against the entire codebase. Especially for the `format` and `inspect` commands. +Using `--self-contained-system` (or `-s`) with backend commands is recommended as it significantly reduces execution time compared to running commands against the entire codebase. Especially for the `format` and `inspect` commands. -- `[CLI_ALIAS] inspect --backend --solution-name BackOffice.slnf` -- `[CLI_ALIAS] format --backend --solution-name AccountManagement.slnf` +- `[CLI_ALIAS] inspect --backend --self-contained-system back-office` +- `[CLI_ALIAS] format --backend --self-contained-system account-management` -The value of the `--solution-name` parameter should be the solution filter file (`.slnf`) name from the self-contained system directory. +The value of the `--self-contained-system` parameter should be the kebab-case name of the self-contained system directory (e.g., `account-management`, `back-office`). ## Troubleshooting when `[CLI_ALIAS]` fails diff --git a/.windsurf/workflows/prepare-pull-request.md b/.windsurf/workflows/prepare-pull-request.md index ac7d8a4ba2..2cdba13066 100644 --- a/.windsurf/workflows/prepare-pull-request.md +++ b/.windsurf/workflows/prepare-pull-request.md @@ -50,7 +50,7 @@ Use this workflow to create pull request titles and descriptions: 6. Build, test, format and inspect the codebase: - If changes have been made to backend `*.cs` but only to one self-contained system, run: ```bash - [CLI_ALIAS] check --backend --solution-name SelfContainedSystem.slnf + [CLI_ALIAS] check --backend --self-contained-system ``` - If backend changes have been made to `*.cs` in multiple self-contained systems or the Shared Kernel, run: ```bash diff --git a/README.md b/README.md index 25213f991e..2a6620eb1c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Run this command to automate Azure Subscription configuration and set up [GitHub ```bash cd developer-cli -dotnet run configure-continuous-deployments # Tip: Add --verbose-logging to show the used CLI commands +dotnet run deploy # Tip: Add --verbose-logging to show the used CLI commands ``` You need to be the owner of the GitHub repository and the Azure Subscription, plus have permissions to create Service Principals and Active Directory Groups. diff --git a/application/account-management/Tests/EndpointBaseTest.cs b/application/account-management/Tests/EndpointBaseTest.cs index c3260f773b..80f6aa55ec 100644 --- a/application/account-management/Tests/EndpointBaseTest.cs +++ b/application/account-management/Tests/EndpointBaseTest.cs @@ -5,6 +5,7 @@ using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; @@ -93,6 +94,12 @@ protected EndpointBaseTest() _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => { + builder.ConfigureLogging(logging => + { + logging.AddFilter(_ => false); // Suppress all logs during tests + } + ); + builder.ConfigureTestServices(services => { // Replace the default DbContext in the WebApplication to use an in-memory SQLite database diff --git a/application/account-management/Tests/SharedKernel/CustomExceptionHandlingTests.cs b/application/account-management/Tests/SharedKernel/CustomExceptionHandlingTests.cs index 46dadf86bd..9aac21ddc1 100644 --- a/application/account-management/Tests/SharedKernel/CustomExceptionHandlingTests.cs +++ b/application/account-management/Tests/SharedKernel/CustomExceptionHandlingTests.cs @@ -12,15 +12,30 @@ public sealed class CustomExceptionHandlingTests : EndpointBaseTest _webApplicationFactory = new(); - [Theory] - [InlineData("Development")] - [InlineData("Production")] - public async Task GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly(string environment) + [Fact] + [TestCategory("Noisy")] + public async Task GlobalExceptionHandling_WhenThrowingExceptionInDevelopment_ShouldHandleExceptionsCorrectly() + { + await GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly("Development"); + } + + [Fact] + public async Task GlobalExceptionHandling_WhenThrowingExceptionInProduction_ShouldHandleExceptionsCorrectly() + { + await GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly("Production"); + } + + internal async Task GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly(string environment) { // Arrange var client = _webApplicationFactory.WithWebHostBuilder(builder => { builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); + builder.ConfigureLogging(logging => + { + logging.AddFilter(_ => false); // Suppress all logs during tests + } + ); builder.ConfigureAppConfiguration((_, _) => { // Set the environment variable to enable the test-specific /api/throwException endpoint. @@ -53,10 +68,20 @@ await response.ShouldHaveErrorStatusCode( } } - [Theory] - [InlineData("Development")] - [InlineData("Production")] - public async Task TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly( + [Fact] + [TestCategory("Noisy")] + public async Task TimeoutExceptionHandling_WhenThrowingTimeoutExceptionInDevelopment_ShouldHandleTimeoutExceptionsCorrectly() + { + await TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly("Development"); + } + + [Fact] + public async Task TimeoutExceptionHandling_WhenThrowingTimeoutExceptionInProduction_ShouldHandleTimeoutExceptionsCorrectly() + { + await TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly("Production"); + } + + internal async Task TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly( string environment ) { @@ -64,6 +89,11 @@ string environment var client = _webApplicationFactory.WithWebHostBuilder(builder => { builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); + builder.ConfigureLogging(logging => + { + logging.AddFilter(_ => false); // Suppress all logs during tests + } + ); builder.ConfigureAppConfiguration((_, _) => { // Set the environment variable to enable the test-specific /api/throwException endpoint. diff --git a/application/back-office/Tests/EndpointBaseTest.cs b/application/back-office/Tests/EndpointBaseTest.cs index 668d297f45..bd999ab0ab 100644 --- a/application/back-office/Tests/EndpointBaseTest.cs +++ b/application/back-office/Tests/EndpointBaseTest.cs @@ -5,6 +5,7 @@ using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; @@ -93,6 +94,12 @@ protected EndpointBaseTest() _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => { + builder.ConfigureLogging(logging => + { + logging.AddFilter(_ => false); // Suppress all logs during tests + } + ); + builder.ConfigureTestServices(services => { // Replace the default DbContext in the WebApplication to use an in-memory SQLite database diff --git a/application/shared-kernel/Tests/TestCategoryAttribute.cs b/application/shared-kernel/Tests/TestCategoryAttribute.cs new file mode 100644 index 0000000000..d4546eab9f --- /dev/null +++ b/application/shared-kernel/Tests/TestCategoryAttribute.cs @@ -0,0 +1,25 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace PlatformPlatform.SharedKernel.Tests; + +/// +/// Categorizes a test for conditional execution. +/// Common categories: "Noisy" (verbose output), "RequiresDocker", "RequiresAzure", "Integration", etc. +/// Use --exclude-category in the Developer CLI to filter out specific test categories. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +[TraitDiscoverer("PlatformPlatform.SharedKernel.Tests.TestCategoryDiscoverer", "PlatformPlatform.SharedKernel.Tests")] +public sealed class TestCategoryAttribute(string category) : Attribute, ITraitAttribute +{ + public string Category { get; } = category; +} + +public class TestCategoryDiscoverer : ITraitDiscoverer +{ + public IEnumerable> GetTraits(IAttributeInfo traitAttribute) + { + var category = traitAttribute.GetNamedArgument(nameof(TestCategoryAttribute.Category)); + yield return new KeyValuePair("Category", category); + } +} diff --git a/developer-cli/Commands/BuildCommand.cs b/developer-cli/Commands/BuildCommand.cs index 0265b8feb1..1becaa0d96 100644 --- a/developer-cli/Commands/BuildCommand.cs +++ b/developer-cli/Commands/BuildCommand.cs @@ -10,67 +10,186 @@ public class BuildCommand : Command { public BuildCommand() : base("build", "Builds a self-contained system") { - var backendOption = new Option("--backend", "-b") { Description = "Run only backend build" }; - var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend build" }; - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to build (only used for backend builds)" }; + var backendOption = new Option("--backend", "-b") { Description = "Build backend code" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Build frontend code" }; + var cliOption = new Option("--cli", "-c") { Description = "Build developer-cli code" }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = "The name of the self-contained system to build (e.g., account-management, back-office)" }; + var quietOption = new Option("--quiet", "-q") { Description = "Minimal output mode" }; Options.Add(backendOption); Options.Add(frontendOption); - Options.Add(solutionNameOption); + Options.Add(cliOption); + Options.Add(selfContainedSystemOption); + Options.Add(quietOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(backendOption), - parseResult.GetValue(frontendOption), - parseResult.GetValue(solutionNameOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(cliOption), + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(quietOption) + ) + ); } - private static void Execute(bool backend, bool frontend, string? solutionName) + private static void Execute(bool backend, bool frontend, bool developerCli, string? selfContainedSystem, bool quiet) { - Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node); + var noFlags = !backend && !frontend && !developerCli; + var buildBackend = backend || noFlags; + var buildFrontend = frontend || noFlags; + var buildDeveloperCli = developerCli || noFlags; - var buildBackend = backend || !frontend; - var buildFrontend = frontend || !backend; + // Ensure prerequisites based on what we're building + if (buildBackend || buildDeveloperCli) Prerequisite.Ensure(Prerequisite.Dotnet); + if (buildFrontend) Prerequisite.Ensure(Prerequisite.Node); try { var startTime = Stopwatch.GetTimestamp(); var backendTime = TimeSpan.Zero; var frontendTime = TimeSpan.Zero; + var developerCliTime = TimeSpan.Zero; if (buildBackend) { - AnsiConsole.MarkupLine("[blue]Running backend build...[/]"); - var solutionFile = SolutionHelper.GetSolution(solutionName); - ProcessHelper.StartProcess($"dotnet build {solutionFile.Name}", solutionFile.Directory?.FullName); + if (!quiet) AnsiConsole.MarkupLine("[blue]Running backend build...[/]"); + + var solutionFile = SelfContainedSystemHelper.GetSolutionFile(selfContainedSystem); + ProcessHelper.Run($"dotnet build {solutionFile.Name}", solutionFile.Directory?.FullName, "Build", quiet); backendTime = Stopwatch.GetElapsedTime(startTime); } if (buildFrontend) { - AnsiConsole.MarkupLine("[blue]Ensure npm packages are up to date...[/]"); - ProcessHelper.StartProcess("npm install", Configuration.ApplicationFolder); + if (!quiet) AnsiConsole.MarkupLine("[blue]Ensure npm packages are up to date...[/]"); + ProcessHelper.Run("npm install --silent", Configuration.ApplicationFolder, "npm install", quiet); - AnsiConsole.MarkupLine("\n[blue]Running frontend build...[/]"); - ProcessHelper.StartProcess("npm run build", Configuration.ApplicationFolder); + if (!quiet) AnsiConsole.MarkupLine("\n[blue]Running frontend build...[/]"); + RunFrontendBuild(quiet); frontendTime = Stopwatch.GetElapsedTime(startTime) - backendTime; } - AnsiConsole.MarkupLine($"[green]Build completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); - if (buildBackend && buildFrontend) + if (buildDeveloperCli) + { + if (!quiet) AnsiConsole.MarkupLine("[blue]Running developer-cli build...[/]"); + RunDeveloperCliBuild(quiet); + developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; + } + + if (quiet) { - AnsiConsole.MarkupLine( - $""" - Backend: [green]{backendTime.Format()}[/] - Frontend: [green]{frontendTime.Format()}[/] - """ - ); + Console.WriteLine("Build succeeded."); + } + else + { + AnsiConsole.MarkupLine($"[green]Build completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); + + var multipleTargets = (buildBackend ? 1 : 0) + (buildFrontend ? 1 : 0) + (buildDeveloperCli ? 1 : 0) > 1; + if (multipleTargets) + { + var timingLines = new List(); + if (buildBackend) timingLines.Add($"Backend: [green]{backendTime.Format()}[/]"); + if (buildFrontend) timingLines.Add($"Frontend: [green]{frontendTime.Format()}[/]"); + if (buildDeveloperCli) timingLines.Add($"Developer CLI: [green]{developerCliTime.Format()}[/]"); + AnsiConsole.MarkupLine(string.Join(Environment.NewLine, timingLines)); + } } } catch (Exception ex) { - AnsiConsole.MarkupLine($"[red]Error during build: {ex.Message}[/]"); + if (quiet) + { + Console.WriteLine($"Build failed: {ex.Message}"); + } + else + { + AnsiConsole.MarkupLine($"[red]Error during build: {ex.Message}[/]"); + } + Environment.Exit(1); } } + + private static void RunFrontendBuild(bool quiet) + { + if (quiet) + { + var result = ProcessHelper.ExecuteQuietly("npm run build", Configuration.ApplicationFolder); + if (!result.Success) + { + var errors = ExtractFrontendErrors(result.CombinedOutput); + Console.WriteLine("Frontend build failed."); + Console.WriteLine(); + Console.WriteLine($"Errors ({errors.Count}):"); + foreach (var error in errors.Take(3)) + { + Console.WriteLine($" {error}"); + } + + if (errors.Count > 3) + { + Console.WriteLine($" ... and {errors.Count - 3} more error(s)"); + } + + Console.WriteLine(); + Console.WriteLine($"Full output: {result.TempFilePathWithSize}"); + Environment.Exit(1); + } + } + else + { + ProcessHelper.StartProcess("npm run build", Configuration.ApplicationFolder); + } + } + + private static List ExtractFrontendErrors(string output) + { + var errors = new List(); + var lines = output.Split('\n'); + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + + // Look for TypeScript error patterns like "Error: TS2551:" or "× Error:" + if (line.Contains("Error: TS") || line.Contains("× Error:")) + { + // Extract file and error message + var errorMessage = line; + + // Try to get the file path from previous lines + for (var j = i - 1; j >= Math.Max(0, i - 3); j--) + { + if (lines[j].Contains("File:") || lines[j].Contains(".tsx:") || lines[j].Contains(".ts:")) + { + var fileLine = lines[j].Trim(); + errorMessage = $"{fileLine} - {errorMessage}"; + break; + } + } + + errors.Add(errorMessage.Replace("[0m", "").Replace("[31m", "").Replace("[39m", "")); + } + } + + // If no TypeScript errors found, look for generic error messages + if (errors.Count == 0) + { + foreach (var line in lines) + { + if (line.Contains("error") && !line.Contains("npm error") && line.Length < 200) + { + errors.Add(line.Trim()); + } + } + } + + return errors.Count > 0 ? errors : ["Build failed. See full output for details."]; + } + + private static void RunDeveloperCliBuild(bool quiet) + { + var solutionFile = new FileInfo(Path.Combine(Configuration.CliFolder, "DeveloperCli.slnx")); + ProcessHelper.Run($"dotnet build {solutionFile.Name}", solutionFile.Directory?.FullName, "Build", quiet); + } } diff --git a/developer-cli/Commands/CheckCommand.cs b/developer-cli/Commands/CheckCommand.cs index 7db3f300ea..5070c7a79e 100644 --- a/developer-cli/Commands/CheckCommand.cs +++ b/developer-cli/Commands/CheckCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.Invocation; using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -11,92 +10,162 @@ public class CheckCommand : Command { public CheckCommand() : base("check", "Performs all checks including build, test, format, and inspect for backend and frontend code") { - var backendOption = new Option("--backend", "-b") { Description = "Run only backend checks" }; - var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend checks" }; - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to check (only used for backend checks)" }; - var skipFormatOption = new Option("--skip-format") { Description = "Skip the backend format step which can be time consuming" }; - var skipInspectOption = new Option("--skip-inspect") { Description = "Skip the backend inspection step which can be time consuming" }; + var backendOption = new Option("--backend", "-b") { Description = "Run backend checks" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Run frontend checks" }; + var cliOption = new Option("--cli", "-c") { Description = "Run developer-cli checks" }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = "The name of the self-contained system to check (e.g., account-management, back-office)" }; + var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring before running checks" }; + var quietOption = new Option("--quiet", "-q") { Description = "Minimal output mode" }; Options.Add(backendOption); Options.Add(frontendOption); - Options.Add(solutionNameOption); - Options.Add(skipFormatOption); - Options.Add(skipInspectOption); - - this.SetAction(parseResult => Execute( - parseResult.GetValue(backendOption), - parseResult.GetValue(frontendOption), - parseResult.GetValue(solutionNameOption), - parseResult.GetValue(skipFormatOption), - parseResult.GetValue(skipInspectOption) - )); + Options.Add(cliOption); + Options.Add(selfContainedSystemOption); + Options.Add(noBuildOption); + Options.Add(quietOption); + + SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(cliOption), + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(noBuildOption), + parseResult.GetValue(quietOption) + ) + ); } - private static void Execute(bool backend, bool frontend, string? solutionName, bool skipFormat, bool skipInspect) + private static void Execute(bool backend, bool frontend, bool cli, string? selfContainedSystem, bool noBuild, bool quiet) { - Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node); + var noFlags = !backend && !frontend && !cli; + var checkBackend = backend || noFlags; + var checkFrontend = frontend || noFlags; + var checkCli = cli || noFlags; - var checkBackend = backend || !frontend; - var checkFrontend = frontend || !backend; + // Ensure prerequisites based on what we're checking + if (checkBackend || checkCli) Prerequisite.Ensure(Prerequisite.Dotnet); + if (checkFrontend) Prerequisite.Ensure(Prerequisite.Node); try { var startTime = Stopwatch.GetTimestamp(); var backendTime = TimeSpan.Zero; var frontendTime = TimeSpan.Zero; + var cliTime = TimeSpan.Zero; if (checkBackend) { - RunBackendChecks(solutionName, skipFormat, skipInspect); + RunBackendChecks(selfContainedSystem, noBuild, quiet); backendTime = Stopwatch.GetElapsedTime(startTime); } if (checkFrontend) { - RunFrontendChecks(); + RunFrontendChecks(noBuild, quiet); frontendTime = Stopwatch.GetElapsedTime(startTime) - backendTime; } - AnsiConsole.MarkupLine($"[green]All checks completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); - if (checkBackend && checkFrontend) + if (checkCli) { - AnsiConsole.MarkupLine( - $""" - Backend: [green]{backendTime.Format()}[/] - Frontend: [green]{frontendTime.Format()}[/] - """ - ); + RunCliChecks(noBuild, quiet); + cliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; + } + + if (quiet) + { + Console.WriteLine("All checks passed."); + } + else + { + AnsiConsole.MarkupLine($"[green]All checks completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); + + var multipleTargets = (checkBackend ? 1 : 0) + (checkFrontend ? 1 : 0) + (checkCli ? 1 : 0) > 1; + if (multipleTargets) + { + var timingLines = new List(); + if (checkBackend) timingLines.Add($"Backend: [green]{backendTime.Format()}[/]"); + if (checkFrontend) timingLines.Add($"Frontend: [green]{frontendTime.Format()}[/]"); + if (checkCli) timingLines.Add($"Developer CLI: [green]{cliTime.Format()}[/]"); + AnsiConsole.MarkupLine(string.Join(Environment.NewLine, timingLines)); + } } } catch (Exception ex) { - AnsiConsole.MarkupLine($"[red]Error during checks: {ex.Message}[/]"); + if (quiet) + { + Console.WriteLine($"Checks failed: {ex.Message}"); + } + else + { + AnsiConsole.MarkupLine($"[red]Error during checks: {ex.Message}[/]"); + } + Environment.Exit(1); } } - private static void RunBackendChecks(string? solutionName, bool skipFormat, bool skipInspect) + private static void RunBackendChecks(string? selfContainedSystem, bool noBuild, bool quiet) { - string[] solutionArgs = solutionName is not null ? ["--solution-name", solutionName] : []; + var systemArgs = BuildArgs(selfContainedSystem, quiet); + + if (!noBuild) + { + new BuildCommand().Parse([.. systemArgs, "--backend"]).Invoke(); + } + + new TestCommand().Parse([.. systemArgs, "--no-build"]).Invoke(); + + string[] formatArgs = noBuild ? ["--no-build"] : []; + new FormatCommand().Parse([.. systemArgs, "--backend", .. formatArgs]).Invoke(); - new BuildCommand().Parse([.. solutionArgs, "--backend"]).Invoke(); - new TestCommand().Parse([.. solutionArgs, "--no-build"]).Invoke(); + new InspectCommand().Parse([.. systemArgs, "--backend", "--no-build"]).Invoke(); + } + + private static void RunFrontendChecks(bool noBuild, bool quiet) + { + string[] args = quiet ? ["--quiet"] : []; - if (!skipFormat) + if (!noBuild) { - new FormatCommand().Parse([.. solutionArgs, "--backend"]).Invoke(); + new BuildCommand().Parse([.. args, "--frontend"]).Invoke(); } - if (!skipInspect) + new FormatCommand().Parse([.. args, "--frontend"]).Invoke(); + new InspectCommand().Parse([.. args, "--frontend"]).Invoke(); + } + + private static void RunCliChecks(bool noBuild, bool quiet) + { + string[] args = quiet ? ["--quiet"] : []; + string[] formatArgs = noBuild ? ["--no-build"] : []; + + if (!noBuild) { - new InspectCommand().Parse([.. solutionArgs, "--backend", "--no-build"]).Invoke(); + new BuildCommand().Parse([.. args, "--cli"]).Invoke(); } + + new FormatCommand().Parse([.. args, "--cli", .. formatArgs]).Invoke(); + new InspectCommand().Parse([.. args, "--cli", "--no-build"]).Invoke(); } - private static void RunFrontendChecks() + private static string[] BuildArgs(string? selfContainedSystem, bool quiet) { - new BuildCommand().Parse(["--frontend"]).Invoke(); - new FormatCommand().Parse(["--frontend"]).Invoke(); - new InspectCommand().Parse(["--frontend"]).Invoke(); + if (selfContainedSystem is not null && quiet) + { + return ["--self-contained-system", selfContainedSystem, "--quiet"]; + } + + if (selfContainedSystem is not null) + { + return ["--self-contained-system", selfContainedSystem]; + } + + if (quiet) + { + return ["--quiet"]; + } + + return []; } } diff --git a/developer-cli/Commands/CoAuthorCommand.cs b/developer-cli/Commands/CoAuthorCommand.cs deleted file mode 100644 index c86568b67f..0000000000 --- a/developer-cli/Commands/CoAuthorCommand.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.CommandLine; -using PlatformPlatform.DeveloperCli.Installation; -using PlatformPlatform.DeveloperCli.Utilities; -using Spectre.Console; - -namespace PlatformPlatform.DeveloperCli.Commands; - -public sealed class CoAuthorCommand : Command -{ - private const string CoAuthorTrailer = "Co-authored-by"; - - public CoAuthorCommand() : base("coauthor", "Amends the current commit and adds you as a co-author") - { - this.SetAction(_ => Execute()); - } - - private static void Execute() - { - var gitUserName = ProcessHelper.StartProcess("git config user.name", Configuration.SourceCodeFolder, true).Trim(); - var gitUserEmail = ProcessHelper.StartProcess("git config user.email", Configuration.SourceCodeFolder, true).Trim(); - - if (string.IsNullOrEmpty(gitUserName) || string.IsNullOrEmpty(gitUserEmail)) - { - AnsiConsole.MarkupLine("[red]Git user name or email not configured.[/]"); - Environment.Exit(1); - } - - var commitAuthor = ProcessHelper.StartProcess( - "git log -1 --format=\"%an <%ae>\"", Configuration.SourceCodeFolder, true, throwOnError: true - ).Trim(); - var currentUser = $"{gitUserName} <{gitUserEmail}>"; - if (commitAuthor == currentUser) - { - AnsiConsole.MarkupLine("[yellow]You are already the author of this commit.[/]"); - Environment.Exit(0); - } - - var stagedChanges = ProcessHelper.StartProcess("git diff --cached --name-only", Configuration.SourceCodeFolder, true); - var hasNoChanges = string.IsNullOrWhiteSpace(stagedChanges); - - var commitMessage = ProcessHelper.StartProcess("git log -1 --format=%B", Configuration.SourceCodeFolder, true).Trim(); - var coAuthorLine = $"{CoAuthorTrailer}: {currentUser}"; - var isAlreadyCoAuthor = commitMessage.Contains(coAuthorLine); - - if (hasNoChanges && isAlreadyCoAuthor) - { - AnsiConsole.MarkupLine("[yellow]No staged changes, and you are already a co-author of this commit.[/]"); - Environment.Exit(0); - } - - if (hasNoChanges && !AnsiConsole.Confirm("No staged changes. Do you want to add co-author information only?")) - { - Environment.Exit(0); - } - - var amendMessage = isAlreadyCoAuthor ? "--no-edit" : $"-m \"{commitMessage.TrimEnd()}\n\n{coAuthorLine}\""; - ProcessHelper.StartProcess($"git commit --amend {amendMessage}", Configuration.SourceCodeFolder); - AnsiConsole.MarkupLine("[green]Successfully amended commit with co-author information.[/]"); - } -} diff --git a/developer-cli/Commands/CoverageCommand.cs b/developer-cli/Commands/CoverageCommand.cs index e42e7d5a68..7c677a1cc8 100644 --- a/developer-cli/Commands/CoverageCommand.cs +++ b/developer-cli/Commands/CoverageCommand.cs @@ -9,18 +9,14 @@ public class CoverageCommand : Command { public CoverageCommand() : base("coverage", "Run JetBrains Code Coverage") { - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to build" }; - - Options.Add(solutionNameOption); - - this.SetAction(parseResult => Execute(parseResult.GetValue(solutionNameOption))); + SetAction(_ => Execute()); } - private static void Execute(string? solutionName) + private static void Execute() { Prerequisite.Ensure(Prerequisite.Dotnet); - var solutionFile = SolutionHelper.GetSolution(solutionName); + var solutionFile = SelfContainedSystemHelper.GetSolutionFile(null); ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); @@ -28,8 +24,10 @@ private static void Execute(string? solutionName) var solutionFileWithoutExtension = solutionFile.Name.Replace(solutionFile.Extension, ""); + var solutionRootNamespace = solutionFileWithoutExtension; // Or adjust as needed + ProcessHelper.StartProcess( - $"dotnet dotcover test {solutionFile.Name} --no-build --dcOutput=coverage/dotCover.html --dcReportType=HTML --dcFilters=\"+:{solutionFileWithoutExtension}.*;-:*.Tests;-:type=*.AppHost.*\"", + $"dotnet dotcover test {solutionFile.Name} --no-build --dcOutput=coverage/dotCover.html --dcReportType=HTML --dcFilters=\"+:{solutionRootNamespace}.*;+:PlatformPlatform.*;-:*.Tests;-:type=*.AppHost.*\"", Configuration.ApplicationFolder ); diff --git a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs b/developer-cli/Commands/DeployCommand.cs similarity index 98% rename from developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs rename to developer-cli/Commands/DeployCommand.cs index 086a6073f4..c077cf9fbe 100644 --- a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs +++ b/developer-cli/Commands/DeployCommand.cs @@ -10,7 +10,7 @@ namespace PlatformPlatform.DeveloperCli.Commands; -public class ConfigureContinuousDeploymentsCommand : Command +public class DeployCommand : Command { private static readonly JsonSerializerOptions? JsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; @@ -20,24 +20,18 @@ public class ConfigureContinuousDeploymentsCommand : Command private List? _configureContinuousDeploymentsExtensions; - public ConfigureContinuousDeploymentsCommand() : base( - "configure-continuous-deployments", + public DeployCommand() : base( + "deploy", "Set up trust between Azure and GitHub for passwordless deployments using OpenID Connect" ) { - var verboseLoggingOption = new Option("--verbose-logging") { Description = "Print Azure and GitHub CLI commands and output" }; - - Options.Add(verboseLoggingOption); - - this.SetAction(parseResult => Execute(parseResult.GetValue(verboseLoggingOption))); + SetAction(_ => Execute()); } - private void Execute(bool verboseLogging = false) + private void Execute() { Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.AzureCli, Prerequisite.GithubCli); - Configuration.VerboseLogging = verboseLogging; - _configureContinuousDeploymentsExtensions = Assembly.GetExecutingAssembly().GetTypes() .Where(t => t.IsSubclassOf(typeof(ConfigureContinuousDeployments))) .Select(t => Activator.CreateInstance(t) as ConfigureContinuousDeployments) @@ -564,7 +558,7 @@ void PrepareSubscription(string subscriptionId) { RunAzureCliCommand( $"provider register --namespace Microsoft.ContainerService --subscription {subscriptionId}", - !Configuration.VerboseLogging + !Configuration.TraceEnabled ); } } @@ -635,8 +629,8 @@ void CreateFederatedCredential(string appRegistrationId, string displayName, str Arguments = $"{(Configuration.IsWindows ? "/C az" : string.Empty)} ad app federated-credential create --id {appRegistrationId} --parameters @-", RedirectStandardInput = true, - RedirectStandardOutput = !Configuration.VerboseLogging, - RedirectStandardError = !Configuration.VerboseLogging + RedirectStandardOutput = !Configuration.TraceEnabled, + RedirectStandardError = !Configuration.TraceEnabled }, parameters, exitOnError: false @@ -655,11 +649,11 @@ void GrantAccess(Subscription subscription, string appRegistrationName) RunAzureCliCommand( $"role assignment create --assignee {servicePrincipalId} --role \"Contributor\" --scope /subscriptions/{subscription.Id}", - !Configuration.VerboseLogging + !Configuration.TraceEnabled ); RunAzureCliCommand( $"role assignment create --assignee {servicePrincipalId} --role \"User Access Administrator\" --scope /subscriptions/{subscription.Id}", - !Configuration.VerboseLogging + !Configuration.TraceEnabled ); AnsiConsole.MarkupLine( @@ -684,7 +678,7 @@ void CreateAzureSqlServerSecurityGroup(SqlAdminsGroup sqlAdminGroup, AppRegistra RunAzureCliCommand( $"ad group member add --group {sqlAdminGroup.ObjectId} --member-id {appRegistration.ServicePrincipalObjectId}", - !Configuration.VerboseLogging + !Configuration.TraceEnabled ); AnsiConsole.MarkupLine( diff --git a/developer-cli/Commands/End2EndCommand.cs b/developer-cli/Commands/End2EndCommand.cs index 33951b5d72..616eed80f1 100644 --- a/developer-cli/Commands/End2EndCommand.cs +++ b/developer-cli/Commands/End2EndCommand.cs @@ -56,9 +56,7 @@ public class End2EndCommand : Command Options.Add(workersOption); // SetHandler only supports up to 8 parameters, so we use SetAction for this complex command - this.SetAction(parseResult => - { - Execute( + SetAction(parseResult => Execute( parseResult.GetValue(searchTermsArgument)!, parseResult.GetValue(browserOption)!, parseResult.GetValue(debugOption), @@ -78,8 +76,8 @@ public class End2EndCommand : Command parseResult.GetValue(stopOnFirstFailureOption), parseResult.GetValue(uiOption), parseResult.GetValue(workersOption) - ); - }); + ) + ); } private static string BaseUrl => Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000"; @@ -170,7 +168,7 @@ private static void Execute( foreach (var currentSelfContainedSystem in selfContainedSystemsToTest) { var selfContainedSystemSuccess = RunTestsForSystem(currentSelfContainedSystem, testPatterns, browser, debug, debugTiming, searchGrep, headed, includeSlow, lastFailed, - onlyChanged, quiet, repeatEach, retries, showReport, slowMo, smoke, stopOnFirstFailure, ui, workers + onlyChanged, repeatEach, retries, showReport, slowMo, smoke, stopOnFirstFailure, ui, workers ); if (!selfContainedSystemSuccess) @@ -266,7 +264,6 @@ private static bool RunTestsForSystem( bool includeSlow, bool lastFailed, bool onlyChanged, - bool quiet, int? repeatEach, int? retries, bool showReport, @@ -277,11 +274,11 @@ private static bool RunTestsForSystem( int? workers) { var systemPath = Path.Combine(Configuration.ApplicationFolder, selfContainedSystem, "WebApp"); - var e2eTestsPath = Path.Combine(systemPath, "tests/e2e"); + var end2EndTestsPath = Path.Combine(systemPath, "tests/e2e"); - if (!Directory.Exists(e2eTestsPath)) + if (!Directory.Exists(end2EndTestsPath)) { - AnsiConsole.MarkupLine($"[yellow]No e2e tests found for {selfContainedSystem}. Skipping...[/]"); + AnsiConsole.MarkupLine($"[yellow]No end-to-end tests found for {selfContainedSystem}. Skipping...[/]"); return true; } @@ -303,7 +300,7 @@ private static bool RunTestsForSystem( var isLocalhost = BaseUrl.Contains("localhost", StringComparison.OrdinalIgnoreCase); var playwrightArgs = BuildPlaywrightArgs( - testPatterns, browser, debug, searchGrep, showBrowser, includeSlow, lastFailed, onlyChanged, quiet, repeatEach, + testPatterns, browser, debug, searchGrep, showBrowser, includeSlow, lastFailed, onlyChanged, repeatEach, retries, runSequential, smoke, stopOnFirstFailure, ui, workers ); @@ -378,17 +375,17 @@ private static string[] DetermineSystemsToTest(string[] testPatterns, string? gr var matchingSystems = new HashSet(); - foreach (var pattern in testPatterns.Where(p => p != null && p != "*")) + foreach (var pattern in testPatterns.Where(p => p != "*")) { var normalizedPattern = pattern.EndsWith(".spec.ts") ? pattern : $"{pattern}.spec.ts"; normalizedPattern = Path.GetFileName(normalizedPattern); foreach (var system in availableSystems) { - var e2eTestsPath = Path.Combine(Configuration.ApplicationFolder, system, "WebApp", "tests", "e2e"); - if (!Directory.Exists(e2eTestsPath)) continue; + var end2EndTestsPath = Path.Combine(Configuration.ApplicationFolder, system, "WebApp", "tests", "e2e"); + if (!Directory.Exists(end2EndTestsPath)) continue; - var testFiles = Directory.GetFiles(e2eTestsPath, "*.spec.ts", SearchOption.AllDirectories) + var testFiles = Directory.GetFiles(end2EndTestsPath, "*.spec.ts", SearchOption.AllDirectories) .Select(Path.GetFileName); if (testFiles.Any(file => file?.Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase) == true)) @@ -404,10 +401,10 @@ private static string[] DetermineSystemsToTest(string[] testPatterns, string? gr { foreach (var system in availableSystems) { - var e2eTestsPath = Path.Combine(Configuration.ApplicationFolder, system, "WebApp", "tests", "e2e"); - if (!Directory.Exists(e2eTestsPath)) continue; + var end2EndTestsPath = Path.Combine(Configuration.ApplicationFolder, system, "WebApp", "tests", "e2e"); + if (!Directory.Exists(end2EndTestsPath)) continue; - var testFiles = Directory.GetFiles(e2eTestsPath, "*.spec.ts", SearchOption.AllDirectories); + var testFiles = Directory.GetFiles(end2EndTestsPath, "*.spec.ts", SearchOption.AllDirectories); foreach (var testFile in testFiles) { // For filename search, remove @ if present for comparison @@ -443,7 +440,6 @@ private static string BuildPlaywrightArgs( bool includeSlow, bool lastFailed, bool onlyChanged, - bool quiet, int? repeatEach, int? retries, bool runSequential, @@ -498,26 +494,6 @@ private static string BuildPlaywrightArgs( return string.Join(" ", args); } - private static string PromptForSelfContainedSystem(string[] availableSystems) - { - if (availableSystems.Length == 0) - { - AnsiConsole.MarkupLine("[red]No self-contained systems found.[/]"); - Environment.Exit(1); - return string.Empty; // This line will never be reached but is needed to satisfy the compiler - } - - var selectedSystem = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("Select a [green]self-contained system[/] to test:") - .PageSize(10) - .MoreChoicesText("[grey](Move up and down to reveal more systems)[/]") - .AddChoices(availableSystems) - ); - - return selectedSystem; - } - private static void OpenHtmlReport(string selfContainedSystem) { var reportPath = Path.Combine(Configuration.ApplicationFolder, selfContainedSystem, "WebApp", "tests", "test-results", "playwright-report", "index.html"); diff --git a/developer-cli/Commands/FormatCommand.cs b/developer-cli/Commands/FormatCommand.cs index eeb761a97d..720dc233d5 100644 --- a/developer-cli/Commands/FormatCommand.cs +++ b/developer-cli/Commands/FormatCommand.cs @@ -12,30 +12,42 @@ public class FormatCommand : Command { public FormatCommand() : base("format", "Formats code to match code styling rules") { - var backendOption = new Option("--backend", "-b") { Description = "Only format backend code" }; - var frontendOption = new Option("--frontend", "-f") { Description = "Only format frontend code" }; - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to format (only used for backend code)" }; + var backendOption = new Option("--backend", "-b") { Description = "Format backend code" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Format frontend code" }; + var cliOption = new Option("--cli", "-c") { Description = "Format developer-cli code" }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = "The name of the self-contained system to format (e.g., account-management, back-office)" }; + var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring before formatting" }; + var quietOption = new Option("--quiet", "-q") { Description = "Minimal output mode" }; Options.Add(backendOption); Options.Add(frontendOption); - Options.Add(solutionNameOption); + Options.Add(cliOption); + Options.Add(selfContainedSystemOption); + Options.Add(noBuildOption); + Options.Add(quietOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(backendOption), - parseResult.GetValue(frontendOption), - parseResult.GetValue(solutionNameOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(cliOption), + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(noBuildOption), + parseResult.GetValue(quietOption) + ) + ); } - private static void Execute(bool backend, bool frontend, string? solutionName) + private static void Execute(bool backend, bool frontend, bool developerCli, string? selfContainedSystem, bool noBuild, bool quiet) { - var formatBackend = backend || !frontend; - var formatFrontend = frontend || !backend; + var noFlags = !backend && !frontend && !developerCli; + var formatBackend = backend || noFlags; + var formatFrontend = frontend || noFlags; + var formatDeveloperCli = developerCli || noFlags; try { - var initialUncommittedFiles = GitHelper.GetChangedFiles(); - if (initialUncommittedFiles.Count > 0) + var initialUncommittedFiles = quiet ? null : GitHelper.GetChangedFiles(); + if (!quiet && initialUncommittedFiles!.Count > 0) { AnsiConsole.MarkupLine("[yellow]Warning: You have unstaged changes in your working directory.[/]"); } @@ -43,56 +55,85 @@ private static void Execute(bool backend, bool frontend, string? solutionName) var startTime = Stopwatch.GetTimestamp(); var backendTime = TimeSpan.Zero; var frontendTime = TimeSpan.Zero; + var developerCliTime = TimeSpan.Zero; if (formatBackend) { Prerequisite.Ensure(Prerequisite.Dotnet); - RunBackendFormat(solutionName); + RunBackendFormat(selfContainedSystem, noBuild, quiet); backendTime = Stopwatch.GetElapsedTime(startTime); } if (formatFrontend) { Prerequisite.Ensure(Prerequisite.Node); - RunFrontendFormat(); + RunFrontendFormat(quiet); frontendTime = Stopwatch.GetElapsedTime(startTime) - backendTime; } - var uncommittedFilesAfterFormat = GitHelper.GetChangedFiles(); - var modifiedFiles = uncommittedFilesAfterFormat - .Where(kvp => !initialUncommittedFiles.TryGetValue(kvp.Key, out var hash) || hash != kvp.Value) - .Select(kvp => kvp.Key) - .ToArray(); - - if (modifiedFiles.Length > 0) + if (formatDeveloperCli) { - AnsiConsole.MarkupLine("[yellow]Warning: Code format modified the following files:[/]"); - AnsiConsole.MarkupLine($"[blue]{string.Join(Environment.NewLine, modifiedFiles)}[/]"); + Prerequisite.Ensure(Prerequisite.Dotnet); + RunDeveloperCliFormat(noBuild, quiet); + developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; } - AnsiConsole.MarkupLine($"[green]Code format completed in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); - if (formatBackend && formatFrontend) + if (quiet) { - AnsiConsole.MarkupLine( - $""" - Backend: [green]{backendTime.Format()}[/] - Frontend: [green]{frontendTime.Format()}[/] - """ - ); + Console.WriteLine("Code formatted successfully."); + } + else + { + var uncommittedFilesAfterFormat = GitHelper.GetChangedFiles(); + var modifiedFiles = uncommittedFilesAfterFormat + .Where(kvp => !initialUncommittedFiles!.TryGetValue(kvp.Key, out var hash) || hash != kvp.Value) + .Select(kvp => kvp.Key) + .ToArray(); + + if (modifiedFiles.Length > 0) + { + AnsiConsole.MarkupLine("[yellow]Warning: Code format modified the following files:[/]"); + AnsiConsole.MarkupLine($"[blue]{string.Join(Environment.NewLine, modifiedFiles)}[/]"); + } + + AnsiConsole.MarkupLine($"[green]Code format completed in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); + + var multipleTargets = (formatBackend ? 1 : 0) + (formatFrontend ? 1 : 0) + (formatDeveloperCli ? 1 : 0) > 1; + if (multipleTargets) + { + var timingLines = new List(); + if (formatBackend) timingLines.Add($"Backend: [green]{backendTime.Format()}[/]"); + if (formatFrontend) timingLines.Add($"Frontend: [green]{frontendTime.Format()}[/]"); + if (formatDeveloperCli) timingLines.Add($"Developer CLI: [green]{developerCliTime.Format()}[/]"); + AnsiConsole.MarkupLine(string.Join(Environment.NewLine, timingLines)); + } } } catch (Exception ex) { - AnsiConsole.MarkupLine($"[red]Error during code format: {ex.Message}[/]"); + if (quiet) + { + Console.WriteLine($"Format failed: {ex.Message}"); + } + else + { + AnsiConsole.MarkupLine($"[red]Error during code format: {ex.Message}[/]"); + } + Environment.Exit(1); } } - private static void RunBackendFormat(string? solutionName) + private static void RunBackendFormat(string? selfContainedSystem, bool noBuild, bool quiet) { - AnsiConsole.MarkupLine("[blue]Running backend code format...[/]"); - var solutionFile = SolutionHelper.GetSolution(solutionName); - ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); + var solutionFile = SelfContainedSystemHelper.GetSolutionFile(selfContainedSystem); + + if (!quiet) AnsiConsole.MarkupLine("[blue]Running backend code format...[/]"); + + if (!noBuild) + { + ProcessHelper.Run("dotnet tool restore", solutionFile.Directory!.FullName, "Tool restore", quiet); + } // .slnx files are not yet supported by JetBrains tools, so we need to create a temporary .slnf file var createTemporarySolutionFile = solutionFile.Extension.Equals(".slnx", StringComparison.OrdinalIgnoreCase); @@ -103,24 +144,57 @@ private static void RunBackendFormat(string? solutionName) ? CreateTemporaryJetBrainsCompatibleSolutionFile(solutionFile) : solutionFile.FullName; - ProcessHelper.StartProcess( + ProcessHelper.Run( $"""dotnet jb cleanupcode {jetbrainsSupportedSolutionFile} --profile=".NET only" --no-build""", - solutionFile.Directory!.FullName + solutionFile.Directory!.FullName, + "Format", + quiet ); } finally { - if (createTemporarySolutionFile) + if (createTemporarySolutionFile && File.Exists(jetbrainsSupportedSolutionFile)) { File.Delete(jetbrainsSupportedSolutionFile); } } } - private static void RunFrontendFormat() + private static void RunFrontendFormat(bool quiet) { - AnsiConsole.MarkupLine("[blue]Running frontend code format...[/]"); - ProcessHelper.StartProcess("npm run lint", Configuration.ApplicationFolder); + if (!quiet) AnsiConsole.MarkupLine("[blue]Running frontend code format...[/]"); + ProcessHelper.Run("npm run lint", Configuration.ApplicationFolder, "Frontend format", quiet); + } + + private static void RunDeveloperCliFormat(bool noBuild, bool quiet) + { + var solutionFile = new FileInfo(Path.Combine(Configuration.CliFolder, "DeveloperCli.slnx")); + + if (!quiet) AnsiConsole.MarkupLine("[blue]Running developer-cli code format...[/]"); + + if (!noBuild) + { + ProcessHelper.Run("dotnet tool restore", solutionFile.Directory!.FullName, "Tool restore", quiet); + } + + // .slnx files are not yet supported by JetBrains tools, so we need to create a temporary .slnf file + var jetbrainsSupportedSolutionFile = CreateTemporaryJetBrainsCompatibleSolutionFile(solutionFile); + try + { + ProcessHelper.Run( + $"""dotnet jb cleanupcode {jetbrainsSupportedSolutionFile} --profile=".NET only" --no-build""", + solutionFile.Directory!.FullName, + "Format", + quiet + ); + } + finally + { + if (File.Exists(jetbrainsSupportedSolutionFile)) + { + File.Delete(jetbrainsSupportedSolutionFile); + } + } } /// diff --git a/developer-cli/Commands/GitConfigCommand.cs b/developer-cli/Commands/GitConfigCommand.cs new file mode 100644 index 0000000000..21b5153ea3 --- /dev/null +++ b/developer-cli/Commands/GitConfigCommand.cs @@ -0,0 +1,418 @@ +using System.CommandLine; +using System.Text.RegularExpressions; +using PlatformPlatform.DeveloperCli.Installation; +using PlatformPlatform.DeveloperCli.Utilities; +using Spectre.Console; + +namespace PlatformPlatform.DeveloperCli.Commands; + +public sealed class GitConfigCommand : Command +{ + private static readonly (string Setting, string Value, string Description)[] RecommendedSettings = + [ + ("push.default", "current", "Always push only the current branch (avoids accidentally pushing other branches)"), + ("push.autoSetupRemote", "true", "Automatically set up remote tracking on first push (no need for 'git push -u')"), + ("push.useForceIfIncludes", "true", "Block force push if your local branch is missing commits from remote (prevents overwriting others' work)"), + ("rerere.enabled", "true", "Remember how you resolved merge conflicts and auto-apply the same resolution next time"), + ("rerere.autoupdate", "true", "Automatically stage files that were auto-resolved by rerere"), + ("fetch.prune", "true", "Automatically remove local references to deleted remote branches when fetching") + ]; + + public GitConfigCommand() : base("git-config", "Configure Git author identity and recommended settings for safer pushes, conflict resolution, and branch management") + { + SetAction(_ => Execute()); + } + + private static void Execute() + { + var repositoryName = new DirectoryInfo(Configuration.SourceCodeFolder).Name; + + AnsiConsole.MarkupLine($"[blue]Git Configuration for {repositoryName}[/]"); + AnsiConsole.WriteLine(); + + var globalConfigPath = GetGitConfigPath(true); + var localConfigPath = GetGitConfigPath(false); + AnsiConsole.MarkupLine($"[dim]Global config:[/] [blue]{globalConfigPath}[/]"); + AnsiConsole.MarkupLine($"[dim]Local config:[/] [blue]{localConfigPath}[/]"); + AnsiConsole.WriteLine(); + + var appliedSettings = new List<(string Setting, string Value, bool IsGlobal)>(); + + ConfigureAuthorIdentity(appliedSettings); + var skipped = ConfigureRecommendedSettings(appliedSettings); + PrintSummary(appliedSettings, skipped); + } + + private static void ConfigureAuthorIdentity(List<(string Setting, string Value, bool IsGlobal)> appliedSettings) + { + AnsiConsole.MarkupLine("For consistency, your local git email should match your GitHub email."); + AnsiConsole.MarkupLine("[dim]Note: When merging pull requests via GitHub's web interface, GitHub uses your primary email from [/][blue]https://github.com/settings/emails[/]"); + AnsiConsole.WriteLine(); + + var localName = GetGitConfig("user.name", false); + var globalName = GetGitConfig("user.name", true); + var localEmail = GetGitConfig("user.email", false); + var globalEmail = GetGitConfig("user.email", true); + + var effectiveName = !string.IsNullOrEmpty(localName) ? localName : globalName; + var effectiveEmail = !string.IsNullOrEmpty(localEmail) ? localEmail : globalEmail; + + if (!string.IsNullOrEmpty(effectiveName)) + { + var nameSource = !string.IsNullOrEmpty(localName) ? "[dim](local)[/]" : "[dim](global)[/]"; + AnsiConsole.MarkupLine($" Name: [green]{effectiveName.EscapeMarkup()}[/] {nameSource}"); + } + else + { + AnsiConsole.MarkupLine(" Name: [red]not set[/]"); + } + + if (!string.IsNullOrEmpty(effectiveEmail)) + { + var emailSource = !string.IsNullOrEmpty(localEmail) ? "[dim](local)[/]" : "[dim](global)[/]"; + AnsiConsole.MarkupLine($" Email: [green]{effectiveEmail.EscapeMarkup()}[/] {emailSource}"); + } + else + { + AnsiConsole.MarkupLine(" Email: [red]not set[/]"); + } + + AnsiConsole.WriteLine(); + + var needsConfiguration = string.IsNullOrEmpty(effectiveName) || string.IsNullOrEmpty(effectiveEmail); + var wantsToChange = needsConfiguration || AnsiConsole.Confirm("Do you want to change this?", false); + + if (!wantsToChange) + { + AnsiConsole.WriteLine(); + return; + } + + var newName = AnsiConsole.Prompt( + new TextPrompt("Enter name:") + .DefaultValue(effectiveName ?? "") + .AllowEmpty() + ); + + var newEmail = AnsiConsole.Prompt( + new TextPrompt("Enter email:") + .DefaultValue(effectiveEmail ?? "") + .AllowEmpty() + ); + + if (string.IsNullOrWhiteSpace(newName) || string.IsNullOrWhiteSpace(newEmail)) + { + AnsiConsole.MarkupLine("[yellow]Name and email are required. Skipping author configuration.[/]"); + AnsiConsole.WriteLine(); + return; + } + + var scope = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Apply name/email globally or for this repository only?") + .AddChoices("Global (recommended)", "This repository only") + ); + + var isGlobal = scope == "Global (recommended)"; + + SetGitConfig("user.name", newName, isGlobal); + SetGitConfig("user.email", newEmail, isGlobal); + + appliedSettings.Add(("user.name", newName, isGlobal)); + appliedSettings.Add(("user.email", newEmail, isGlobal)); + + AnsiConsole.WriteLine(); + } + + private static bool ConfigureRecommendedSettings(List<(string Setting, string Value, bool IsGlobal)> appliedSettings) + { + AnsiConsole.MarkupLine("The following settings are recommended for all developers:"); + AnsiConsole.WriteLine(); + + var table = new Table(); + table.AddColumn("Setting"); + table.AddColumn("Current"); + table.AddColumn("Recommended"); + table.AddColumn("Description"); + table.Border(TableBorder.Rounded); + + var settingsToApply = new List<(string Setting, string Value)>(); + var settingsAlreadySet = new List<(string Setting, string Value, bool IsGlobal)>(); + + foreach (var (setting, recommendedValue, description) in RecommendedSettings) + { + var globalValue = GetGitConfig(setting, true); + var localValue = GetGitConfig(setting, false); + var currentValue = globalValue ?? localValue; + var currentDisplay = currentValue ?? "[dim]not set[/]"; + var alreadySet = currentValue == recommendedValue; + + if (alreadySet) + { + var isGlobal = globalValue == recommendedValue; + table.AddRow(setting, $"[green]{currentDisplay}[/]", recommendedValue, $"[dim]{description}[/]"); + settingsAlreadySet.Add((setting, recommendedValue, isGlobal)); + } + else + { + table.AddRow(setting, currentDisplay, $"[green]{recommendedValue}[/]", description); + settingsToApply.Add((setting, recommendedValue)); + } + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + if (settingsToApply.Count == 0) + { + AnsiConsole.MarkupLine("[green]All recommended settings are already configured![/]"); + + if (AnsiConsole.Confirm("Do you want to remove any settings?", false)) + { + var removedSettings = RemoveSettingsOneByOne(settingsAlreadySet); + PrintRemovalSummary(removedSettings); + } + + AnsiConsole.WriteLine(); + return false; + } + + var choices = new List + { + $"Apply all {settingsToApply.Count} settings globally (recommended)", + $"Apply all {settingsToApply.Count} settings to this repository only", + "Ask for each setting one by one", + "Skip" + }; + + if (settingsAlreadySet.Count > 0) + { + choices.Insert(choices.Count - 1, "Remove existing settings one by one"); + } + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Apply {settingsToApply.Count} missing setting(s)?") + .AddChoices(choices) + ); + + if (choice == "Skip") + { + AnsiConsole.MarkupLine("[dim]Skipping recommended settings.[/]"); + AnsiConsole.WriteLine(); + return true; + } + + if (choice == "Remove existing settings one by one") + { + var removedSettings = RemoveSettingsOneByOne(settingsAlreadySet); + PrintRemovalSummary(removedSettings); + AnsiConsole.WriteLine(); + return false; + } + + if (choice == "Ask for each setting one by one") + { + AnsiConsole.WriteLine(); + foreach (var (setting, value) in settingsToApply) + { + var description = RecommendedSettings.First(s => s.Setting == setting).Description; + AnsiConsole.MarkupLine($"[yellow]{setting}[/] = [green]{value}[/]"); + AnsiConsole.MarkupLine($"[dim]{description}[/]"); + + var settingChoice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Apply this setting?") + .AddChoices("Global (recommended)", "This repository only", "Skip") + ); + + if (settingChoice != "Skip") + { + var isGlobal = settingChoice == "Global (recommended)"; + SetGitConfig(setting, value, isGlobal); + appliedSettings.Add((setting, value, isGlobal)); + } + + AnsiConsole.WriteLine(); + } + } + else + { + var isGlobal = choice.Contains("globally"); + + foreach (var (setting, value) in settingsToApply) + { + SetGitConfig(setting, value, isGlobal); + appliedSettings.Add((setting, value, isGlobal)); + } + } + + AnsiConsole.WriteLine(); + return false; + } + + private static List<(string Setting, bool IsGlobal)> RemoveSettingsOneByOne(List<(string Setting, string Value, bool IsGlobal)> settings) + { + var removedSettings = new List<(string Setting, bool IsGlobal)>(); + + AnsiConsole.WriteLine(); + foreach (var (setting, value, isGlobal) in settings) + { + var description = RecommendedSettings.First(s => s.Setting == setting).Description; + var scopeLabel = isGlobal ? "global" : "local"; + AnsiConsole.MarkupLine($"[yellow]{setting}[/] = [green]{value}[/] [dim]({scopeLabel})[/]"); + AnsiConsole.MarkupLine($"[dim]{description}[/]"); + + if (AnsiConsole.Confirm("Remove this setting?", false)) + { + UnsetGitConfig(setting, isGlobal); + AnsiConsole.MarkupLine($"[red]Removed {setting}[/]"); + removedSettings.Add((setting, isGlobal)); + } + + AnsiConsole.WriteLine(); + } + + return removedSettings; + } + + private static void PrintRemovalSummary(List<(string Setting, bool IsGlobal)> removedSettings) + { + if (removedSettings.Count == 0) + { + return; + } + + AnsiConsole.MarkupLine("[yellow]Settings removed:[/]"); + var globalRemoved = removedSettings.Where(s => s.IsGlobal).ToList(); + var localRemoved = removedSettings.Where(s => !s.IsGlobal).ToList(); + + if (globalRemoved.Count > 0) + { + AnsiConsole.MarkupLine("[dim]Global:[/]"); + foreach (var (setting, _) in globalRemoved) + { + AnsiConsole.MarkupLine($" [red]✗[/] {setting}"); + } + } + + if (localRemoved.Count > 0) + { + if (globalRemoved.Count > 0) AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Repository:[/]"); + foreach (var (setting, _) in localRemoved) + { + AnsiConsole.MarkupLine($" [red]✗[/] {setting}"); + } + } + } + + private static void PrintSummary(List<(string Setting, string Value, bool IsGlobal)> appliedSettings, bool skipped) + { + if (appliedSettings.Count == 0) + { + if (skipped) + { + AnsiConsole.MarkupLine("[yellow]No changes were made.[/]"); + } + + return; + } + + AnsiConsole.MarkupLine("[green]Git configuration complete![/]"); + AnsiConsole.WriteLine(); + + var globalSettings = appliedSettings.Where(s => s.IsGlobal).ToList(); + var localSettings = appliedSettings.Where(s => !s.IsGlobal).ToList(); + + if (globalSettings.Count > 0) + { + AnsiConsole.MarkupLine("[dim]Global settings applied:[/]"); + foreach (var (setting, value, _) in globalSettings) + { + AnsiConsole.MarkupLine($" [green]✓[/] {setting} = {value.EscapeMarkup()}"); + } + } + + if (localSettings.Count > 0) + { + if (globalSettings.Count > 0) AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Repository settings applied:[/]"); + foreach (var (setting, value, _) in localSettings) + { + AnsiConsole.MarkupLine($" [green]✓[/] {setting} = {value.EscapeMarkup()}"); + } + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[yellow]Tip:[/] Consider setting up commit signing for verified commits: [blue]https://docs.github.com/en/authentication/managing-commit-signature-verification[/]"); + } + + private static string GetGitConfigPath(bool global) + { + var scope = global ? "--global" : "--local"; + var result = ProcessHelper.StartProcess( + $"git config {scope} --list --show-origin", + Configuration.SourceCodeFolder, + true, + exitOnError: false + ); + + var defaultPath = global + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gitconfig") + : Path.Combine(Configuration.SourceCodeFolder, ".git", "config"); + + if (string.IsNullOrWhiteSpace(result)) + { + return defaultPath; + } + + var firstLine = result.Split('\n').FirstOrDefault() ?? ""; + var match = Regex.Match(firstLine, @"file:(.+?)\t"); + if (match.Success) + { + var path = match.Groups[1].Value; + if (!Path.IsPathRooted(path)) + { + path = Path.GetFullPath(Path.Combine(Configuration.SourceCodeFolder, path)); + } + + return path; + } + + return defaultPath; + } + + private static string? GetGitConfig(string setting, bool global) + { + var scope = global ? "--global" : "--local"; + var result = ProcessHelper.StartProcess( + $"git config {scope} {setting}", + Configuration.SourceCodeFolder, + true, + exitOnError: false + ); + return string.IsNullOrWhiteSpace(result) ? null : result.Trim(); + } + + private static void SetGitConfig(string setting, string value, bool global) + { + var scope = global ? "--global" : "--local"; + ProcessHelper.StartProcess( + $"git config {scope} {setting} \"{value}\"", + Configuration.SourceCodeFolder, + true + ); + } + + private static void UnsetGitConfig(string setting, bool global) + { + var scope = global ? "--global" : "--local"; + ProcessHelper.StartProcess( + $"git config {scope} --unset {setting}", + Configuration.SourceCodeFolder, + true, + exitOnError: false + ); + } +} diff --git a/developer-cli/Commands/InspectCommand.cs b/developer-cli/Commands/InspectCommand.cs index 7d723e2517..63622b39ab 100644 --- a/developer-cli/Commands/InspectCommand.cs +++ b/developer-cli/Commands/InspectCommand.cs @@ -10,99 +10,204 @@ public class InspectCommand : Command { public InspectCommand() : base("inspect", "Run code inspections for frontend and backend code") { - var backendOption = new Option("--backend", "-b") { Description = "Run only backend inspections" }; - var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend inspections" }; - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to inspect (only used for backend inspections)" }; + var backendOption = new Option("--backend", "-b") { Description = "Run backend inspections" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Run frontend inspections" }; + var cliOption = new Option("--cli", "-c") { Description = "Run developer-cli inspections" }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = "The name of the self-contained system to inspect (e.g., account-management, back-office)" }; var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring the solution before running inspections" }; + var quietOption = new Option("--quiet", "-q") { Description = "Minimal output mode" }; Options.Add(backendOption); Options.Add(frontendOption); - Options.Add(solutionNameOption); + Options.Add(cliOption); + Options.Add(selfContainedSystemOption); Options.Add(noBuildOption); + Options.Add(quietOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(backendOption), - parseResult.GetValue(frontendOption), - parseResult.GetValue(solutionNameOption), - parseResult.GetValue(noBuildOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(cliOption), + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(noBuildOption), + parseResult.GetValue(quietOption) + ) + ); } - private static void Execute(bool backend, bool frontend, string? solutionName, bool noBuild) + private static void Execute(bool backend, bool frontend, bool developerCli, string? selfContainedSystem, bool noBuild, bool quiet) { - var inspectBackend = backend || !frontend; - var inspectFrontend = frontend || !backend; + var noFlags = !backend && !frontend && !developerCli; + var inspectBackend = backend || noFlags; + var inspectFrontend = frontend || noFlags; + var inspectDeveloperCli = developerCli || noFlags; try { var startTime = Stopwatch.GetTimestamp(); var backendTime = TimeSpan.Zero; var frontendTime = TimeSpan.Zero; + var developerCliTime = TimeSpan.Zero; + var hasIssues = false; if (inspectBackend) { Prerequisite.Ensure(Prerequisite.Dotnet); - RunBackendInspections(solutionName, noBuild); + hasIssues = RunBackendInspections(selfContainedSystem, noBuild, quiet); backendTime = Stopwatch.GetElapsedTime(startTime); } if (inspectFrontend) { Prerequisite.Ensure(Prerequisite.Node); - RunFrontendInspections(); + RunFrontendInspections(quiet); frontendTime = Stopwatch.GetElapsedTime(startTime) - backendTime; } - AnsiConsole.MarkupLine($"[green]Code inspections completed in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); - if (inspectBackend && inspectFrontend) + if (inspectDeveloperCli) { - AnsiConsole.MarkupLine( - $""" - Backend: [green]{backendTime.Format()}[/] - Frontend: [green]{frontendTime.Format()}[/] - """ - ); + Prerequisite.Ensure(Prerequisite.Dotnet); + var developerCliHasIssues = RunDeveloperCliInspections(noBuild, quiet); + hasIssues = hasIssues || developerCliHasIssues; + developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; + } + + if (quiet) + { + if (hasIssues) + { + Console.WriteLine("Issues found. Check result.json in the project directories."); + Environment.Exit(1); + } + + Console.WriteLine("Inspections completed successfully. No issues found."); + } + else + { + AnsiConsole.MarkupLine($"[green]Code inspections completed in {Stopwatch.GetElapsedTime(startTime).Format()}[/]"); + + var multipleTargets = (inspectBackend ? 1 : 0) + (inspectFrontend ? 1 : 0) + (inspectDeveloperCli ? 1 : 0) > 1; + if (multipleTargets) + { + var timingLines = new List(); + if (inspectBackend) timingLines.Add($"Backend: [green]{backendTime.Format()}[/]"); + if (inspectFrontend) timingLines.Add($"Frontend: [green]{frontendTime.Format()}[/]"); + if (inspectDeveloperCli) timingLines.Add($"Developer CLI: [green]{developerCliTime.Format()}[/]"); + AnsiConsole.MarkupLine(string.Join(Environment.NewLine, timingLines)); + } + + if (hasIssues) + { + Environment.Exit(1); + } } } catch (Exception ex) { - AnsiConsole.MarkupLine($"[red]Error during code inspections: {ex.Message}[/]"); + if (quiet) + { + Console.WriteLine($"Inspections failed: {ex.Message}"); + } + else + { + AnsiConsole.MarkupLine($"[red]Error during code inspections: {ex.Message}[/]"); + } + Environment.Exit(1); } } - private static void RunBackendInspections(string? solutionName, bool noBuild) + private static bool RunBackendInspections(string? selfContainedSystem, bool noBuild, bool quiet) { - AnsiConsole.MarkupLine("[blue]Running backend code inspections...[/]"); - var solutionFile = SolutionHelper.GetSolution(solutionName); - - ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); + var solutionFile = SelfContainedSystemHelper.GetSolutionFile(selfContainedSystem); if (!noBuild) { - ProcessHelper.StartProcess($"dotnet build {solutionFile.Name}", solutionFile.Directory!.FullName); + if (!quiet) AnsiConsole.MarkupLine("[blue]Running backend code inspections...[/]"); + ProcessHelper.Run("dotnet tool restore", solutionFile.Directory!.FullName, "Tool restore", quiet); + ProcessHelper.Run($"dotnet build {solutionFile.Name}", solutionFile.Directory!.FullName, "Build", quiet); + } + + // Delete existing result.json to prevent reading stale results + var resultJsonPath = Path.Combine(solutionFile.Directory!.FullName, "result.json"); + if (File.Exists(resultJsonPath)) + { + File.Delete(resultJsonPath); } - ProcessHelper.StartProcess( + ProcessHelper.Run( $"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION", - solutionFile.Directory!.FullName + solutionFile.Directory!.FullName, + "Inspections", + quiet ); var resultJson = File.ReadAllText(Path.Combine(solutionFile.Directory!.FullName, "result.json")); - if (resultJson.Contains("\"results\": [],")) - { - AnsiConsole.MarkupLine("[green]No backend issues found![/]"); - } - else + var hasIssues = !resultJson.Contains("\"results\": [],"); + + if (!quiet) { - AnsiConsole.MarkupLine("[yellow]Backend issues found. Opening result.json...[/]"); - ProcessHelper.StartProcess("code result.json", solutionFile.Directory!.FullName); + if (hasIssues) + { + AnsiConsole.MarkupLine("[yellow]Backend issues found. Opening result.json...[/]"); + ProcessHelper.StartProcess("code result.json", solutionFile.Directory!.FullName); + } + else + { + AnsiConsole.MarkupLine("[green]No backend issues found![/]"); + } } + + return hasIssues; } - private static void RunFrontendInspections() + private static void RunFrontendInspections(bool quiet) { - AnsiConsole.MarkupLine("[blue]Running frontend type checking...[/]"); - ProcessHelper.StartProcess("npm run check", Configuration.ApplicationFolder); + if (!quiet) AnsiConsole.MarkupLine("[blue]Running frontend type checking...[/]"); + ProcessHelper.Run("npm run check", Configuration.ApplicationFolder, "Frontend type checking", quiet); + } + + private static bool RunDeveloperCliInspections(bool noBuild, bool quiet) + { + var solutionFile = new FileInfo(Path.Combine(Configuration.CliFolder, "DeveloperCli.slnx")); + + if (!noBuild) + { + if (!quiet) AnsiConsole.MarkupLine("[blue]Running developer-cli code inspections...[/]"); + ProcessHelper.Run("dotnet tool restore", solutionFile.Directory!.FullName, "Tool restore", quiet); + ProcessHelper.Run($"dotnet build {solutionFile.Name}", solutionFile.Directory!.FullName, "Build", quiet); + } + + // Delete existing result.json to prevent reading stale results + var resultJsonPath = Path.Combine(solutionFile.Directory!.FullName, "result.json"); + if (File.Exists(resultJsonPath)) + { + File.Delete(resultJsonPath); + } + + ProcessHelper.Run( + $"dotnet jb inspectcode {solutionFile.Name} --no-build --no-restore --output=result.json --severity=SUGGESTION", + solutionFile.Directory!.FullName, + "Inspections", + quiet + ); + + var resultJson = File.ReadAllText(Path.Combine(solutionFile.Directory!.FullName, "result.json")); + var hasIssues = !resultJson.Contains("\"results\": [],"); + + if (!quiet) + { + if (hasIssues) + { + AnsiConsole.MarkupLine("[yellow]Developer-cli issues found. Opening result.json...[/]"); + ProcessHelper.StartProcess("code result.json", solutionFile.Directory!.FullName); + } + else + { + AnsiConsole.MarkupLine("[green]No developer-cli issues found![/]"); + } + } + + return hasIssues; } } diff --git a/developer-cli/Commands/InstallCommand.cs b/developer-cli/Commands/InstallCommand.cs index d17cbcd92d..eb8f4f6c3f 100644 --- a/developer-cli/Commands/InstallCommand.cs +++ b/developer-cli/Commands/InstallCommand.cs @@ -40,19 +40,22 @@ public InstallCommand() : base( $"This will register the alias {Configuration.AliasName} so it will be available everywhere" ) { - this.SetAction(_ => Execute()); + var forceOption = new Option("--force", "-f") { Description = "Force reinstall even if already installed" }; + Options.Add(forceOption); + + SetAction(parseResult => Execute(parseResult.GetValue(forceOption))); } - private static void Execute() + private static void Execute(bool force) { Prerequisite.Ensure(Prerequisite.Dotnet); - if (IsAliasRegistered()) + if (!force && IsAliasRegistered()) { var installedAliasPath = Configuration.GetConfigurationSetting().CliSourceCodeFolder!; AnsiConsole.MarkupLine(Environment.ProcessPath!.StartsWith(installedAliasPath) - ? $"[yellow]The CLI is already installed please run {Configuration.AliasName} to use it.[/]" - : $"[yellow]There is already a CLI with the alias '{Configuration.AliasName}' installed in {installedAliasPath}.[/]" + ? $"[yellow]The CLI is already installed please run {Configuration.AliasName} to use it. Use --force to reinstall.[/]" + : $"[yellow]There is already a CLI with the alias '{Configuration.AliasName}' installed in {installedAliasPath}. Use --force to reinstall.[/]" ); Environment.Exit(0); @@ -67,16 +70,11 @@ private static void Execute() RegisterAlias(); } - if (Configuration.IsWindows) - { - AnsiConsole.MarkupLine("Please restart your terminal to update your PATH."); - } - else - { - AnsiConsole.MarkupLine( - $"Please restart your terminal to update your PATH (or run [green]source ~/{Configuration.MacOs.GetShellInfo().ProfileName}[/])." - ); - } + AnsiConsole.MarkupLine( + Configuration.IsWindows + ? "Please restart your terminal to update your PATH." + : $"Please restart your terminal to update your PATH (or run [green]source ~/{Configuration.MacOs.GetShellInfo().ProfileName}[/])." + ); } private static bool IsAliasRegistered() diff --git a/developer-cli/Commands/McpCommand.cs b/developer-cli/Commands/McpCommand.cs new file mode 100644 index 0000000000..3b67502699 --- /dev/null +++ b/developer-cli/Commands/McpCommand.cs @@ -0,0 +1,174 @@ +using System.CommandLine; +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using PlatformPlatform.DeveloperCli.Installation; +using PlatformPlatform.DeveloperCli.Utilities; + +namespace PlatformPlatform.DeveloperCli.Commands; + +public class McpCommand : Command +{ + public McpCommand() : base("mcp", "Start MCP server for AI integration") + { + SetAction(async _ => await ExecuteAsync()); + } + + private static async Task ExecuteAsync() + { + // MCP server mode - all output to stderr to keep stdout clean + await Console.Error.WriteLineAsync("[MCP] Starting MCP server..."); + await Console.Error.WriteLineAsync("[MCP] Listening on stdio for MCP communication"); + + var builder = Host.CreateApplicationBuilder(); + builder.Logging.AddConsole(consoleLogOptions => { consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + + await builder.Build().RunAsync(); + } +} + +[McpServerToolType] +public static class DeveloperCliMcpTools +{ + [McpServerTool] + [Description("Execute developer CLI commands: build, test, format, or inspect code")] + public static async Task ExecuteCommand( + [Description("Command to execute: 'build', 'test', 'format', or 'inspect'")] + string command, + [Description("Backend")] bool backend = false, + [Description("Frontend")] bool frontend = false, + [Description("Self-contained system, e.g., 'account-management' (optional)")] + string? selfContainedSystem = null, + [Description("Skip build (for test, format, inspect only)")] + bool noBuild = false, + [Description("Filter tests by name (test command only)")] + string? filter = null, + [Description("Developer CLI")] bool cli = false) + { + var validCommands = new[] { "build", "test", "format", "inspect" }; + if (!validCommands.Contains(command)) + { + return $"Invalid command: '{command}'. Valid commands: {string.Join(", ", validCommands)}"; + } + + var args = new List { command, "--quiet" }; + + // Add target flags - if none specified, command will run all targets + if (backend) args.Add("--backend"); + if (frontend) args.Add("--frontend"); + if (cli) args.Add("--cli"); + + if (selfContainedSystem is not null) + { + args.Add("--self-contained-system"); + args.Add(selfContainedSystem); + } + + if (noBuild && command != "build") + { + args.Add("--no-build"); + } + + if (filter is not null && command == "test") + { + args.Add("--filter"); + args.Add(filter); + } + + return await ExecuteCliCommandAsync(args.ToArray()); + } + + [McpServerTool] + [Description("Restart .NET Aspire and run database migrations at https://localhost:9000. Runs in the background so you can continue working while it starts.")] + public static string Watch() + { + // Call watch command in detached mode - don't wait for process exit + var developerCliPath = Path.Combine(Configuration.SourceCodeFolder, "developer-cli"); + var args = new List { "run", "--project", developerCliPath, "watch", "--detach", "--force" }; + + var processStartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = string.Join(" ", args), + WorkingDirectory = Configuration.SourceCodeFolder, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process(); + process.StartInfo = processStartInfo; + var output = new List(); + var errors = new List(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) output.Add(e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) errors.Add(e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Wait briefly to capture startup messages, then return (don't wait for full exit) + Thread.Sleep(TimeSpan.FromSeconds(3)); + + if (process.HasExited && process.ExitCode != 0) + { + return $"Failed to start Aspire.\n\n{string.Join("\n", output)}\n{string.Join("\n", errors)}"; + } + + return "Aspire started successfully in detached mode at https://localhost:9000"; + } + + [McpServerTool] + [Description("Run end-to-end tests")] + public static async Task End2End( + [Description("Search terms")] string[]? searchTerms = null, + [Description("Browser")] string browser = "all", + [Description("Smoke only")] bool smoke = false) + { + var args = new List { "e2e", "--quiet" }; + if (searchTerms is { Length: > 0 }) args.AddRange(searchTerms); + if (browser != "all") + { + args.Add("--browser"); + args.Add(browser); + } + + if (smoke) args.Add("--smoke"); + + return await ExecuteCliCommandAsync(args.ToArray()); + } + + [McpServerTool] + [Description("Synchronize AI rules between different AI editors")] + public static async Task SyncAiRules() + { + return await ExecuteCliCommandAsync(["sync-ai-rules"]); + } + + private static async Task ExecuteCliCommandAsync(string[] args) + { + var developerCliPath = Path.Combine(Configuration.SourceCodeFolder, "developer-cli"); + var allArgs = new List { "run", "--project", developerCliPath, "--" }; + allArgs.AddRange(args); + + var command = $"dotnet {string.Join(" ", allArgs.Select(arg => arg.Contains(" ") ? $"\"{arg}\"" : arg))}"; + var result = await ProcessHelper.ExecuteQuietlyAsync(command, Configuration.SourceCodeFolder); + + return result.CombinedOutput; + } +} diff --git a/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs b/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs index aa91e540d3..8d9488bca0 100644 --- a/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs +++ b/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.Invocation; using System.Text; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -16,29 +15,26 @@ public class PullPlatformPlatformChangesCommand : Command public PullPlatformPlatformChangesCommand() : base("pull-platformplatform-changes", "Pull new updates from PlatformPlatform into a pull-request branch") { - var verboseLoggingOption = new Option("--verbose-logging") { Description = "Show git command and output" }; var autoConfirmOption = new Option("--auto-confirm", "-a") { Description = "Auto confirm picking all upstream pull-requests" }; var resumeOption = new Option("--resume", "-r") { Description = "Validate current branch and resume pulling updates starting with rerunning checks" }; var runFormatOption = new Option("--run-format", "-s") { Description = "Run JetBrains format of backend code (slow)" }; - Options.Add(verboseLoggingOption); Options.Add(autoConfirmOption); Options.Add(resumeOption); Options.Add(runFormatOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(verboseLoggingOption), - parseResult.GetValue(autoConfirmOption), - parseResult.GetValue(resumeOption), - parseResult.GetValue(runFormatOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(autoConfirmOption), + parseResult.GetValue(resumeOption), + parseResult.GetValue(runFormatOption) + ) + ); } - private static void Execute(bool verboseLogging, bool autoConfirm, bool resume, bool runCodeFormat) + private static void Execute(bool autoConfirm, bool resume, bool runCodeFormat) { Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node, Prerequisite.GithubCli); - Configuration.VerboseLogging = verboseLogging; Configuration.AutoConfirm = autoConfirm; EnsureValidGitState(); @@ -106,6 +102,7 @@ private static void EnsureValidGitState() GitHelper.EnsureUpstreamRemoteExists(PlatformplatformGitPath); GitHelper.EnsureBranchIsUpToDate(); + GitHelper.EnsureLocalBranchInSyncWithOrigin(TrunkBranchName); } private static Commit[] GetNewCommitsFromPlatformPlatform() @@ -276,12 +273,14 @@ private static void BuildTestAndFormatCode(bool runCodeFormat) { try { - var checkCommand = new CheckCommand(); - var args = runCodeFormat - ? new[] { "--skip-inspect" } - : new[] { "--skip-format", "--skip-inspect" }; + new BuildCommand().Parse([]).Invoke(); + new TestCommand().Parse(["--no-build"]).Invoke(); + + if (runCodeFormat) + { + new FormatCommand().Parse(["--no-build"]).Invoke(); + } - checkCommand.Parse(args).Invoke(); break; } catch (Exception) @@ -396,7 +395,7 @@ private static void PreparePullRequest(Commit[] pullrequestCommits) ); var pullRequestDescription = body.ToString(); - AnsiConsole.MarkupLine(pullRequestDescription); + AnsiConsole.MarkupLine(pullRequestDescription.EscapeMarkup()); AnsiConsole.Confirm("Copy the above text as a description and use it for the pull request description. Continue?"); GitHelper.PushBranch(PullRequestBranchName); diff --git a/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs b/developer-cli/Commands/SyncAiRulesAndWorkflowsCommand.cs similarity index 97% rename from developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs rename to developer-cli/Commands/SyncAiRulesAndWorkflowsCommand.cs index 49df2b6606..5199012032 100644 --- a/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs +++ b/developer-cli/Commands/SyncAiRulesAndWorkflowsCommand.cs @@ -5,11 +5,11 @@ namespace PlatformPlatform.DeveloperCli.Commands; -public sealed class SyncWindsurfAiRulesAndWorkflowsCommand : Command +public sealed class SyncAiRulesAndWorkflowsCommand : Command { - public SyncWindsurfAiRulesAndWorkflowsCommand() : base("sync-windsurf-ai-rules", "Sync Windsurf AI rules from .cursor/rules to .windsurf/rules and .windsurf/workflows, converting frontmatter and deleting orphans.") + public SyncAiRulesAndWorkflowsCommand() : base("sync-ai-rules", "Sync AI rules from .cursor/rules to .windsurf/rules and .windsurf/workflows, converting frontmatter and deleting orphans.") { - this.SetAction(_ => Execute()); + SetAction(_ => Execute()); } private static void Execute() diff --git a/developer-cli/Commands/TestCommand.cs b/developer-cli/Commands/TestCommand.cs index 21f3731bcc..dd1c8cc611 100644 --- a/developer-cli/Commands/TestCommand.cs +++ b/developer-cli/Commands/TestCommand.cs @@ -1,6 +1,9 @@ using System.CommandLine; +using System.Diagnostics; +using System.Text.RegularExpressions; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; +using Spectre.Console; namespace PlatformPlatform.DeveloperCli.Commands; @@ -8,29 +11,287 @@ public class TestCommand : Command { public TestCommand() : base("test", "Runs tests from a solution") { - var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the solution file containing the tests to run" }; + var backendOption = new Option("--backend", "-b") { Description = "This command is always only backend. The option is only here for consistency." }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = "The name of the self-contained system to test (e.g., account-management, back-office)" }; var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring the solution before running tests" }; + var quietOption = new Option("--quiet", "-q") { Description = "Minimal output mode" }; + var filterOption = new Option("--filter") { Description = "Filter tests by name (dotnet test --filter)" }; + var excludeCategoryOption = new Option("--exclude-category") { Description = "Exclude tests by category (e.g., 'Noisy', 'RequiresDocker'). Defaults to 'Noisy'." }; - Options.Add(solutionNameOption); + Options.Add(backendOption); + Options.Add(selfContainedSystemOption); Options.Add(noBuildOption); + Options.Add(quietOption); + Options.Add(filterOption); + Options.Add(excludeCategoryOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(solutionNameOption), - parseResult.GetValue(noBuildOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(noBuildOption), + parseResult.GetValue(quietOption), + parseResult.GetValue(filterOption), + parseResult.GetValue(excludeCategoryOption) + ) + ); } - private void Execute(string? solutionName, bool noBuild) + private void Execute(string? selfContainedSystem, bool noBuild, bool quiet, string? filter, string? excludeCategory) { Prerequisite.Ensure(Prerequisite.Dotnet); - var solutionFile = SolutionHelper.GetSolution(solutionName); + try + { + var solutionFile = SelfContainedSystemHelper.GetSolutionFile(selfContainedSystem); + + if (!noBuild) + { + var buildCommand = quiet + ? $"dotnet build {solutionFile.Name}" + : $"dotnet build {solutionFile.Name} --verbosity quiet"; + + ProcessHelper.Run(buildCommand, solutionFile.Directory?.FullName, "Build", quiet); + } + + var filterArgument = BuildFilterArgument(filter, excludeCategory); + var testCommand = $"""dotnet test {solutionFile.Name} --no-build --no-restore --logger "console;verbosity=normal"{filterArgument}"""; + + if (quiet) + { + RunTestsQuietly(testCommand, solutionFile.Directory?.FullName); + } + else + { + RunTestsWithFilteredOutput(testCommand, solutionFile.Directory?.FullName); + } + } + catch (Exception ex) + { + Console.WriteLine($"Tests failed: {ex.Message}"); + Environment.Exit(1); + } + } + + private static void RunTestsWithFilteredOutput(string command, string? workingDirectory) + { + if (Configuration.TraceEnabled) + { + AnsiConsole.MarkupLine($"[cyan]{Markup.Escape(command)}[/]"); + } + + var stats = new TestStats(); + var stopwatch = Stopwatch.StartNew(); + + // Parse command to get executable and arguments + var parts = command.Split(' ', 2); + var executable = parts[0]; + var arguments = parts.Length > 1 ? parts[1] : ""; + + var processStartInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + if (workingDirectory != null) + { + processStartInfo.WorkingDirectory = workingDirectory; + } + + using var process = Process.Start(processStartInfo)!; + + // Stream stdout in real-time + while (!process.StandardOutput.EndOfStream) + { + var line = process.StandardOutput.ReadLine(); + if (line == null) continue; + + if (ShouldFilterLine(line)) continue; + + var trimmedLine = line.TrimStart(); + + // Count test results + if (trimmedLine.StartsWith("Passed ")) + { + stats.Passed++; + Console.WriteLine(line); + } + else if (trimmedLine.StartsWith("Failed ")) + { + stats.Failed++; + stats.FailedTests.Add(ExtractTestName(line)); + Console.WriteLine(line); + } + else if (trimmedLine.StartsWith("Skipped ")) + { + stats.Skipped++; + Console.WriteLine(line); + } + else if (!string.IsNullOrWhiteSpace(line)) + { + // Print other non-filtered lines (e.g., error details, stack traces) + Console.WriteLine(line); + } + } + + process.WaitForExit(); + stopwatch.Stop(); + var duration = stopwatch.Elapsed.TotalSeconds; + + // Print our summary + Console.WriteLine(); + Console.WriteLine($"Test summary: total: {stats.Total}; failed: {stats.Failed}; succeeded: {stats.Passed}; skipped: {stats.Skipped}; duration: {duration:F1}s"); + + if (process.ExitCode != 0) + { + Environment.Exit(process.ExitCode); + } + } + + private static void RunTestsQuietly(string command, string? workingDirectory) + { + var stopwatch = Stopwatch.StartNew(); + var result = ProcessHelper.ExecuteQuietly(command, workingDirectory); + stopwatch.Stop(); + + var stats = ParseTestOutput(result.StdOut); + var duration = stopwatch.Elapsed.TotalSeconds; + + // Print summary + Console.WriteLine($"Test summary: total: {stats.Total}; failed: {stats.Failed}; succeeded: {stats.Passed}; skipped: {stats.Skipped}; duration: {duration:F1}s"); + + // If failures, show failed test names + link to log + if (stats.Failed > 0) + { + Console.WriteLine("Failed tests:"); + foreach (var test in stats.FailedTests) + { + Console.WriteLine($" {test}"); + } + + Console.WriteLine($"Full output: {result.TempFilePathWithSize}"); + Environment.Exit(1); + } - if (!noBuild) + if (result.ExitCode != 0) { - ProcessHelper.StartProcess($"dotnet build {solutionFile.Name}", solutionFile.Directory?.FullName); + Console.WriteLine($"Full output: {result.TempFilePathWithSize}"); + Environment.Exit(result.ExitCode); } + } + + private static TestStats ParseTestOutput(string output) + { + var stats = new TestStats(); + + foreach (var line in output.Split('\n')) + { + var trimmedLine = line.TrimStart(); + if (trimmedLine.StartsWith("Passed ")) + { + stats.Passed++; + } + else if (trimmedLine.StartsWith("Failed ")) + { + stats.Failed++; + stats.FailedTests.Add(ExtractTestName(line)); + } + else if (trimmedLine.StartsWith("Skipped ")) + { + stats.Skipped++; + } + } + + return stats; + } + + private static bool ShouldFilterLine(string line) + { + if (string.IsNullOrWhiteSpace(line)) return true; + + var trimmedLine = line.TrimStart(); + + // Filter xUnit adapter noise + if (trimmedLine.StartsWith("[xUnit.net")) return true; + + // Filter VSTest noise + if (trimmedLine.StartsWith("Test run for ")) return true; + if (trimmedLine.StartsWith("VSTest version ")) return true; + if (trimmedLine.StartsWith("Microsoft (R) Test Execution")) return true; + if (trimmedLine.StartsWith("Copyright (c) Microsoft")) return true; + if (trimmedLine.StartsWith("Starting test execution")) return true; + if (trimmedLine.StartsWith("A total of ")) return true; + + // Filter per-assembly summary lines (we generate our own) + if (trimmedLine.StartsWith("Test Run Successful.")) return true; + if (trimmedLine.StartsWith("Test Run Failed.")) return true; + if (Regex.IsMatch(trimmedLine, "^Total tests:")) return true; + if (Regex.IsMatch(trimmedLine, @"^\s*Passed\s*:")) return true; + if (Regex.IsMatch(trimmedLine, @"^\s*Failed\s*:")) return true; + if (Regex.IsMatch(trimmedLine, @"^\s*Skipped\s*:")) return true; + if (Regex.IsMatch(trimmedLine, "^Total time:")) return true; + + return false; + } + + private static string ExtractTestName(string line) + { + // Line format: " Failed TestNamespace.TestClass.TestMethod [duration]" + var trimmed = line.Trim(); + if (trimmed.StartsWith("Failed ")) + { + var testPart = trimmed[7..]; // Remove "Failed " + var bracketIndex = testPart.LastIndexOf('['); + if (bracketIndex > 0) + { + return testPart[..(bracketIndex - 1)].Trim(); + } + + return testPart.Trim(); + } + + return trimmed; + } + + private static string BuildFilterArgument(string? userFilter, string? excludeCategory) + { + // By default, exclude "Noisy" category tests unless user explicitly specifies otherwise + // Use empty string to disable default exclusion + var categoryToExclude = excludeCategory ?? "Noisy"; + var categoryFilter = string.IsNullOrEmpty(categoryToExclude) ? "" : $"Category!={categoryToExclude}"; + + if (userFilter is not null && categoryFilter != "") + { + // Combine user filter with category exclusion using AND (&) + return $""" --filter "({userFilter})&{categoryFilter}" """; + } + + if (userFilter is not null) + { + return $""" --filter "{userFilter}" """; + } + + if (categoryFilter != "") + { + return $""" --filter "{categoryFilter}" """; + } + + return ""; + } + + private class TestStats + { + public int Passed { get; set; } + + public int Failed { get; set; } + + public int Skipped { get; set; } + + public int Total => Passed + Failed + Skipped; - ProcessHelper.StartProcess($"dotnet test {solutionFile.Name} --no-build --no-restore", solutionFile.Directory?.FullName); + public List FailedTests { get; } = []; } } diff --git a/developer-cli/Commands/TranslateCommand.cs b/developer-cli/Commands/TranslateCommand.cs index e7ef82d589..5368aba567 100644 --- a/developer-cli/Commands/TranslateCommand.cs +++ b/developer-cli/Commands/TranslateCommand.cs @@ -3,10 +3,12 @@ using System.Text; using Azure.AI.OpenAI; using Karambolo.PO; +using Microsoft.Extensions.AI; using OpenAI.Chat; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; using Spectre.Console; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; namespace PlatformPlatform.DeveloperCli.Commands; @@ -23,10 +25,11 @@ public TranslateCommand() : base( Options.Add(selfContainedSystemOption); Options.Add(languageOption); - this.SetAction(async parseResult => await Execute( - parseResult.GetValue(selfContainedSystemOption), - parseResult.GetValue(languageOption) - )); + SetAction(async parseResult => await Execute( + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(languageOption) + ) + ); } private static async Task Execute(string? selfContainedSystem, string? language) @@ -197,14 +200,10 @@ public async Task> Translate(IReadOnlyColle toReturn.Add(translated); } - if (toReturn.Count > 0) - { - AnsiConsole.MarkupLine($"[green]{toReturn.Count} entries have been translated.[/]"); - } - else - { - AnsiConsole.MarkupLine("[yellow]No entries were translated.[/]"); - } + AnsiConsole.MarkupLine(toReturn.Count > 0 + ? $"[green]{toReturn.Count} entries have been translated.[/]" + : "[yellow]No entries were translated.[/]" + ); return toReturn; } @@ -300,32 +299,26 @@ You are a translation service translating from {sourceLanguage} to {targetLangua } } - private sealed class OpenAiTranslationService + private sealed class OpenAiTranslationService(IChatClient chatClient) { - public const string ModelName = "gpt-4o"; - private readonly ChatClient _client; - public readonly Gpt4OUsageStatistics UsageStatistics = new(); - - private OpenAiTranslationService(ChatClient chatClient) - { - _client = chatClient; - } + public const string ModelName = "gpt-5-mini"; + public readonly Gpt5MiniUsageStatistics UsageStatistics = new(); public static OpenAiTranslationService Create() { var (apiKey, endpoint) = GetApiKeyAndEndpoint(); - ChatClient chatClient; + IChatClient chatClient; if (endpoint is null) { // Use standard OpenAI client for default endpoint - chatClient = new ChatClient(ModelName, apiKey); + chatClient = new ChatClient(ModelName, apiKey).AsIChatClient(); } else { // Use Azure OpenAI client for custom endpoints var azureClient = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); - chatClient = azureClient.GetChatClient(ModelName); + chatClient = azureClient.GetChatClient(ModelName).AsIChatClient(); } return new OpenAiTranslationService(chatClient); @@ -374,67 +367,66 @@ StatusContext context { var messages = new List { - new SystemChatMessage(systemPrompt) + new(ChatRole.System, systemPrompt) }; foreach (var translation in existingTranslations) { - messages.Add(new UserChatMessage(translation.Key.Id)); - messages.Add(new AssistantChatMessage(translation.GetTranslation())); + messages.Add(new ChatMessage(ChatRole.User, translation.Key.Id)); + messages.Add(new ChatMessage(ChatRole.Assistant, translation.GetTranslation())); } - messages.Add(new UserChatMessage(nonTranslatedEntry.Key.Id)); + messages.Add(new ChatMessage(ChatRole.User, nonTranslatedEntry.Key.Id)); context.Status("Translating (thinking...)"); StringBuilder content = new(); - var streamingUpdate = _client.CompleteChatStreamingAsync(messages); - await foreach (var update in streamingUpdate) + var streamingUpdates = new List(); + await foreach (var update in chatClient.GetStreamingResponseAsync(messages)) { - if (update.Usage is not null) - { - UsageStatistics.Update(update.Usage); - } - - content.Append(update.ContentUpdate.FirstOrDefault()?.Text ?? ""); + streamingUpdates.Add(update); + content.Append(update.Text); var percent = Math.Round(content.Length / (nonTranslatedEntry.Key.Id.Length * 1.2) * 100); // +20% is a guess context.Status($"Translating {Math.Min(100, percent)}%"); } + // Get usage from the aggregated response + var completedResponse = streamingUpdates.ToChatResponse(); + if (completedResponse.Usage is not null) + { + UsageStatistics.Update(completedResponse.Usage); + } + context.Status("Translating 100%"); var translated = content.ToString(); return nonTranslatedEntry.ApplyTranslation(translated); } - public record Gpt4OUsageStatistics + public record Gpt5MiniUsageStatistics { - private const decimal NonCachedInputPricePerThousandTokens = 0.0025m; - private const decimal CachedInputPricePerThousandTokens = 0.00125m; - private const decimal OutputPricePerThousandTokens = 0.01m; - private int _cachedInputTokenCount; + private const decimal InputPricePerThousandTokens = 0.00025m; + private const decimal OutputPricePerThousandTokens = 0.002m; - private int _nonCachedInputTokenCount; - private int _outputTokenCount; + private long _inputTokenCount; + private long _outputTokenCount; public decimal TotalCost { get { - var nonCachedInputCost = _nonCachedInputTokenCount / 1000m * NonCachedInputPricePerThousandTokens; - var cachedInputPrice = _cachedInputTokenCount / 1000m * CachedInputPricePerThousandTokens; + var inputCost = _inputTokenCount / 1000m * InputPricePerThousandTokens; var outputCost = _outputTokenCount / 1000m * OutputPricePerThousandTokens; - return nonCachedInputCost + cachedInputPrice + outputCost; + return inputCost + outputCost; } } - public int TotalTokens => _nonCachedInputTokenCount + _outputTokenCount + _cachedInputTokenCount; + public long TotalTokens => _inputTokenCount + _outputTokenCount; - public void Update(ChatTokenUsage usage) + public void Update(UsageDetails usage) { - _cachedInputTokenCount += usage.InputTokenDetails.CachedTokenCount; - _nonCachedInputTokenCount += usage.InputTokenCount - usage.InputTokenDetails.CachedTokenCount; - _outputTokenCount += usage.OutputTokenCount; + _inputTokenCount += usage.InputTokenCount ?? 0; + _outputTokenCount += usage.OutputTokenCount ?? 0; } } } @@ -442,66 +434,72 @@ public void Update(ChatTokenUsage usage) public static class Extensions { - public static string GetTranslation(this POSingularEntry poEntry) + extension(POSingularEntry poEntry) { - var translation = poEntry.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(translation)) + public string GetTranslation() { - throw new InvalidOperationException("No translation was found."); - } - - return translation; - } + var translation = poEntry.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(translation)) + { + throw new InvalidOperationException("No translation was found."); + } - public static bool HasTranslation(this POSingularEntry poEntry) - { - return !string.IsNullOrWhiteSpace(poEntry.Translation); - } + return translation; + } - public static POSingularEntry ReverseKeyAndTranslation(this POSingularEntry poEntry) - { - var key = new POKey(poEntry.GetTranslation(), null, poEntry.Key.ContextId); - var entry = new POSingularEntry(key) + public bool HasTranslation() { - Translation = poEntry.Key.Id, - Comments = poEntry.Comments - }; - - return entry; - } + return !string.IsNullOrWhiteSpace(poEntry.Translation); + } - public static POSingularEntry ApplyTranslation(this POSingularEntry poEntry, string translation) - { - return new POSingularEntry(poEntry.Key) + public POSingularEntry ReverseKeyAndTranslation() { - Translation = translation, - Comments = poEntry.Comments - }; - } + var key = new POKey(poEntry.GetTranslation(), null, poEntry.Key.ContextId); + var entry = new POSingularEntry(key) + { + Translation = poEntry.Key.Id, + Comments = poEntry.Comments + }; - public static IReadOnlyCollection EnsureOnlySingularEntries(this POCatalog catalog) - { - if (catalog.Values.Any(x => x is not POSingularEntry)) - { - throw new NotSupportedException("Only single translations are supported."); + return entry; } - return catalog.Values.OfType().ToArray(); + public POSingularEntry ApplyTranslation(string translation) + { + return new POSingularEntry(poEntry.Key) + { + Translation = translation, + Comments = poEntry.Comments + }; + } } - public static void UpdateEntry(this POCatalog poCatalog, POSingularEntry translatedEntry) + extension(POCatalog catalog) { - var key = translatedEntry.Key; - var poEntry = poCatalog[key]; - if (poEntry is POSingularEntry) + public IReadOnlyCollection EnsureOnlySingularEntries() { - var index = poCatalog.IndexOf(poEntry); - poCatalog.Remove(key); - poCatalog.Insert(index, translatedEntry); + if (catalog.Values.Any(x => x is not POSingularEntry)) + { + throw new NotSupportedException("Only single translations are supported."); + } + + return catalog.Values.OfType().ToArray(); } - else + + public void UpdateEntry(POSingularEntry translatedEntry) { - throw new InvalidOperationException($"Plural is currently not supported. Key: '{key.Id}'"); + var key = translatedEntry.Key; + var poEntry = catalog[key]; + if (poEntry is POSingularEntry) + { + var index = catalog.IndexOf(poEntry); + catalog.Remove(key); + catalog.Insert(index, translatedEntry); + } + else + { + throw new InvalidOperationException($"Plural is currently not supported. Key: '{key.Id}'"); + } } } } diff --git a/developer-cli/Commands/UninstallCommand.cs b/developer-cli/Commands/UninstallCommand.cs index ca0cabdb75..1a6aa428fe 100644 --- a/developer-cli/Commands/UninstallCommand.cs +++ b/developer-cli/Commands/UninstallCommand.cs @@ -8,7 +8,7 @@ public class UninstallCommand : Command { public UninstallCommand() : base("uninstall", $"Will remove the {Configuration.AliasName} CLI alias") { - this.SetAction(_ => Execute()); + SetAction(_ => Execute()); } private void Execute() diff --git a/developer-cli/Commands/UpdatePackagesCommand.cs b/developer-cli/Commands/UpdatePackagesCommand.cs index 0ce96a7ab7..f0c5300369 100644 --- a/developer-cli/Commands/UpdatePackagesCommand.cs +++ b/developer-cli/Commands/UpdatePackagesCommand.cs @@ -31,13 +31,14 @@ public sealed class UpdatePackagesCommand : Command Options.Add(excludeOption); Options.Add(skipUpdateDotnetOption); - this.SetAction(async parseResult => await Execute( - parseResult.GetValue(backendOption), - parseResult.GetValue(frontendOption), - parseResult.GetValue(dryRunOption), - parseResult.GetValue(excludeOption), - parseResult.GetValue(skipUpdateDotnetOption) - )); + SetAction(async parseResult => await Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(dryRunOption), + parseResult.GetValue(excludeOption), + parseResult.GetValue(skipUpdateDotnetOption) + ) + ); } private static async Task Execute(bool backend, bool frontend, bool dryRun, string? exclude, bool skipUpdateDotnet) @@ -79,7 +80,7 @@ private static async Task Execute(bool backend, bool frontend, bool dryRun, stri // Update .csproj files that have inline PackageReference versions (not using central package management) foreach (var csprojFile in Directory.GetFiles(Configuration.SourceCodeFolder, "*.csproj", SearchOption.AllDirectories)) { - await UpdateNuGetPackagesAsync(csprojFile, "PackageReference", dryRun, excludedPackages, requireVersionAttribute: true); + await UpdateNuGetPackagesAsync(csprojFile, "PackageReference", dryRun, excludedPackages, true); } UpdateAspireSdkVersion(dryRun); @@ -241,8 +242,7 @@ private static async Task UpdateNuGetPackagesAsync(string filePath, string eleme while (packagesToCheckDependencies.Count > 0) { var packageName = packagesToCheckDependencies.Dequeue(); - if (checkedPackages.Contains(packageName)) continue; - checkedPackages.Add(packageName); + if (!checkedPackages.Add(packageName)) continue; var update = candidatePackageUpdates[packageName]; @@ -920,34 +920,6 @@ private static int GetMajorVersion(string version) return new Version(version).Major; } - private static void ValidateBackend() - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[blue]Running backend validation...[/]"); - - // Run pp build --backend - AnsiConsole.MarkupLine("[dim]Running: pp build --backend[/]"); - ProcessHelper.StartProcess("pp build --backend", Configuration.SourceCodeFolder); - - // Run pp test - AnsiConsole.MarkupLine("[dim]Running: pp test[/]"); - ProcessHelper.StartProcess("pp test", Configuration.SourceCodeFolder); - - AnsiConsole.MarkupLine("[green]✓ Backend validation completed successfully![/]"); - } - - private static void ValidateFrontend() - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[blue]Running frontend validation...[/]"); - - // Run pp build --frontend - AnsiConsole.MarkupLine("[dim]Running: pp build --frontend[/]"); - ProcessHelper.StartProcess("pp build --frontend", Configuration.SourceCodeFolder); - - AnsiConsole.MarkupLine("[green]✓ Frontend validation completed successfully![/]"); - } - private static void UpdateAspireSdkVersion(bool dryRun) { var appHostPath = Path.Combine(Configuration.ApplicationFolder, "AppHost", "AppHost.csproj"); @@ -1057,7 +1029,6 @@ private static async Task UpdateDotnetToolsAsync(bool dryRun) foreach (var dotnetToolsPath in dotnetToolsFiles) { - var fileName = Path.GetFileName(dotnetToolsPath); var relativePath = Path.GetRelativePath(Configuration.SourceCodeFolder, dotnetToolsPath); AnsiConsole.WriteLine(); @@ -1420,11 +1391,12 @@ private static async Task GetLatestDotnetMajorVersion() .GetProperty("releases-index") .EnumerateArray() .Select(release => - { - var channelVersion = release.GetProperty("channel-version").GetString()!; - var parts = channelVersion.Split('.'); - return int.Parse(parts[0]); - }) + { + var channelVersion = release.GetProperty("channel-version").GetString()!; + var parts = channelVersion.Split('.'); + return int.Parse(parts[0]); + } + ) .Max(); return latestMajor; diff --git a/developer-cli/Commands/WatchCommand.cs b/developer-cli/Commands/WatchCommand.cs index 93c77a4a60..a9b4342e6f 100644 --- a/developer-cli/Commands/WatchCommand.cs +++ b/developer-cli/Commands/WatchCommand.cs @@ -28,13 +28,14 @@ public WatchCommand() : base("watch", "Manages Aspire AppHost operations") Options.Add(detachOption); Options.Add(publicUrlOption); - this.SetAction(parseResult => Execute( - parseResult.GetValue(forceOption), - parseResult.GetValue(stopOption), - parseResult.GetValue(attachOption), - parseResult.GetValue(detachOption), - parseResult.GetValue(publicUrlOption) - )); + SetAction(parseResult => Execute( + parseResult.GetValue(forceOption), + parseResult.GetValue(stopOption), + parseResult.GetValue(attachOption), + parseResult.GetValue(detachOption), + parseResult.GetValue(publicUrlOption) + ) + ); } private static void Execute(bool force, bool stop, bool attach, bool detach, string? publicUrl) @@ -137,8 +138,7 @@ private static void StopAspire() if (!int.TryParse(address[(portIndex + 1)..], out var port) || port < 9000 || port > 9999) continue; var pid = parts[^1]; - if (processedPids.Contains(pid)) continue; - processedPids.Add(pid); + if (!processedPids.Add(pid)) continue; var processName = ProcessHelper.StartProcess($"""wmic process where ProcessId={pid} get Name /format:list""", redirectOutput: true, exitOnError: false); @@ -233,14 +233,11 @@ private static void StartAspireAppHost(bool attach, string? publicUrl) { // For Windows in detached mode, use "start" command to truly detach var detachedCommand = $"cmd /c start \"Aspire AppHost\" /min {command}"; - if (publicUrl is not null) - { - ProcessHelper.StartProcess($"{detachedCommand} --environment PUBLIC_URL={publicUrl}", Configuration.ApplicationFolder, waitForExit: false); - } - else - { - ProcessHelper.StartProcess(detachedCommand, Configuration.ApplicationFolder, waitForExit: false); - } + ProcessHelper.StartProcess( + publicUrl is not null ? $"{detachedCommand} --environment PUBLIC_URL={publicUrl}" : detachedCommand, + Configuration.ApplicationFolder, + waitForExit: false + ); // Give it a moment to start Thread.Sleep(2000); @@ -276,7 +273,7 @@ private static void StartNgrokIfNeeded(string publicUrl) var subdomain = uri.Host.Split('.')[0]; // Check if ngrok is already running - var isNgrokRunning = false; + bool isNgrokRunning; if (Configuration.IsWindows) { @@ -300,15 +297,11 @@ private static void StartNgrokIfNeeded(string publicUrl) // Start ngrok in detached mode var ngrokCommand = $"ngrok http --url={subdomain}.ngrok-free.app https://localhost:9000"; - if (Configuration.IsWindows) - { - ProcessHelper.StartProcess($"start /B {ngrokCommand}", waitForExit: false); - } - else - { - // Use shell to handle backgrounding properly - ProcessHelper.StartProcess($"sh -c \"{ngrokCommand} > /dev/null 2>&1 &\"", waitForExit: false); - } + // Use shell to handle backgrounding properly on macOS/Linux + ProcessHelper.StartProcess( + Configuration.IsWindows ? $"start /B {ngrokCommand}" : $"sh -c \"{ngrokCommand} > /dev/null 2>&1 &\"", + waitForExit: false + ); AnsiConsole.MarkupLine("[green]Ngrok tunnel started successfully.[/]"); } diff --git a/developer-cli/DeveloperCli.csproj b/developer-cli/DeveloperCli.csproj index 2a592540ae..da3f25cab6 100644 --- a/developer-cli/DeveloperCli.csproj +++ b/developer-cli/DeveloperCli.csproj @@ -18,7 +18,12 @@ + + + + + diff --git a/developer-cli/Installation/Configuration.cs b/developer-cli/Installation/Configuration.cs index fa916b05f5..72670d2792 100644 --- a/developer-cli/Installation/Configuration.cs +++ b/developer-cli/Installation/Configuration.cs @@ -33,7 +33,7 @@ public static class Configuration private static string ConfigFile => Path.Combine(PublishFolder, $"{AliasName}.json"); - public static bool VerboseLogging { get; set; } + public static bool TraceEnabled { get; set; } public static bool AutoConfirm { get; set; } @@ -131,18 +131,30 @@ internal static bool IsAliasRegisteredMacOs() return false; } + if (!File.Exists(GetShellInfo().ProfilePath)) + { + return false; + } + return Array.Exists(File.ReadAllLines(GetShellInfo().ProfilePath), line => line == AliasLineRepresentation); } internal static void RegisterAliasMacOs() { - if (!File.Exists(GetShellInfo().ProfilePath)) + var profilePath = GetShellInfo().ProfilePath; + + if (string.IsNullOrEmpty(profilePath)) { AnsiConsole.MarkupLine($"[red]Your shell [bold]{GetShellInfo().ShellName}[/] is not supported.[/]"); return; } - File.AppendAllLines(GetShellInfo().ProfilePath, [AliasLineRepresentation]); + if (!File.Exists(profilePath)) + { + File.Create(profilePath).Dispose(); + } + + File.AppendAllLines(profilePath, [AliasLineRepresentation]); } public static void DeleteAlias() @@ -178,7 +190,7 @@ public static (string ShellName, string ProfileName, string ProfilePath) GetShel public class ConfigurationSetting { - public string? CliSourceCodeFolder { get; set; } + public string? CliSourceCodeFolder { get; init; } public string? Hash { get; set; } diff --git a/developer-cli/Program.cs b/developer-cli/Program.cs index 9ece06edd8..5b34a415ed 100644 --- a/developer-cli/Program.cs +++ b/developer-cli/Program.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.Invocation; using System.Reflection; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -22,20 +21,33 @@ // Preprocess arguments to handle @ symbols in search terms args = CommandLineArgumentsPreprocessor.PreprocessArguments(args); +// Check if running MCP command - skip all output to keep stdout clean for MCP protocol +var isMcpCommand = args.Length > 0 && args[0] == "mcp"; var solutionName = new DirectoryInfo(Configuration.SourceCodeFolder).Name; -if (args.Length == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "-?")) + +if (!isMcpCommand && !args.Contains("-q") && !args.Contains("--quiet")) { - var figletText = new FigletText(solutionName); - AnsiConsole.Write(figletText); -} + if (args.Length == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "-?")) + { + var figletText = new FigletText(solutionName); + AnsiConsole.Write(figletText); + } -AnsiConsole.WriteLine($"Source code folder: {Configuration.SourceCodeFolder} \n"); + AnsiConsole.WriteLine($"Source code folder: {Configuration.SourceCodeFolder} \n"); +} var rootCommand = new RootCommand { Description = $"Welcome to the {solutionName} Developer CLI!" }; +var traceOption = new Option("--trace") +{ + Description = "Show external processes being executed", + Recursive = true +}; +rootCommand.Options.Add(traceOption); + var allCommands = Assembly.GetExecutingAssembly().GetTypes() .Where(t => !t.IsAbstract && t.IsAssignableTo(typeof(Command))) .Select(Activator.CreateInstance) @@ -54,4 +66,5 @@ } var parseResult = rootCommand.Parse(args); +Configuration.TraceEnabled = parseResult.GetValue(traceOption); return await parseResult.InvokeAsync(); diff --git a/developer-cli/Utilities/CommandLineArgumentsPreprocessor.cs b/developer-cli/Utilities/CommandLineArgumentsPreprocessor.cs index a59461c19f..e4737af2c1 100644 --- a/developer-cli/Utilities/CommandLineArgumentsPreprocessor.cs +++ b/developer-cli/Utilities/CommandLineArgumentsPreprocessor.cs @@ -13,10 +13,8 @@ public static class CommandLineArgumentsPreprocessor public static string[] PreprocessArguments(string[] args) { var result = new List(); - for (var i = 0; i < args.Length; i++) + foreach (var arg in args) { - var arg = args[i]; - // Handle positional arguments that start with @ (for e2e search terms) if (arg.StartsWith("@")) { diff --git a/developer-cli/Utilities/GitHelper.cs b/developer-cli/Utilities/GitHelper.cs index 304ad1a3ff..d6e58f715f 100644 --- a/developer-cli/Utilities/GitHelper.cs +++ b/developer-cli/Utilities/GitHelper.cs @@ -218,6 +218,18 @@ public static void PushBranch(string branchName) } } + public static void EnsureLocalBranchInSyncWithOrigin(string branchName) + { + var localHash = ProcessHelper.StartProcess($"git rev-parse {branchName}", Configuration.SourceCodeFolder, true).Trim(); + var originHash = ProcessHelper.StartProcess($"git rev-parse {DefaultRemote}/{branchName}", Configuration.SourceCodeFolder, true).Trim(); + + if (localHash != originHash) + { + AnsiConsole.MarkupLine($"[red]Your local '{branchName}' branch is not in sync with '{DefaultRemote}/{branchName}'. Please pull or push changes before running this command.[/]"); + Environment.Exit(1); + } + } + public static int GetPullRequestNumber(Commit commit, string pattern) { var match = Regex.Match(commit.Message, pattern); diff --git a/developer-cli/Utilities/ProcessHelper.cs b/developer-cli/Utilities/ProcessHelper.cs index 1aee4cfd66..25c1c85fe3 100644 --- a/developer-cli/Utilities/ProcessHelper.cs +++ b/developer-cli/Utilities/ProcessHelper.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using PlatformPlatform.DeveloperCli.Installation; using Spectre.Console; @@ -6,6 +7,38 @@ namespace PlatformPlatform.DeveloperCli.Utilities; public static class ProcessHelper { + private static readonly string TempOutputDirectory = Path.Combine(Path.GetTempPath(), "platformplatform-mcp"); + + static ProcessHelper() + { + // Ensure temp directory exists + Directory.CreateDirectory(TempOutputDirectory); + + // Clean up old temp files (older than 24 hours) + CleanupOldTempFiles(); + } + + private static void CleanupOldTempFiles() + { + try + { + if (!Directory.Exists(TempOutputDirectory)) return; + + var cutoffTime = DateTime.UtcNow.AddHours(-24); + foreach (var file in Directory.GetFiles(TempOutputDirectory, "*.log")) + { + if (File.GetCreationTimeUtc(file) < cutoffTime) + { + File.Delete(file); + } + } + } + catch + { + // Ignore cleanup errors + } + } + public static void StartProcessWithSystemShell(string command, string? solutionFolder = null) { var processStartInfo = CreateProcessStartInfo(command, solutionFolder, useShellExecute: true, createNoWindow: false); @@ -63,6 +96,87 @@ private static ProcessStartInfo CreateProcessStartInfo( return processStartInfo; } + /// + /// Executes a command in either quiet or verbose mode. + /// In quiet mode: captures output and shows error summary on failure. + /// In verbose mode: shows output in real-time. + /// + public static async Task RunAsync(string command, string? workingDirectory = null, string operationName = "", bool quiet = false) + { + if (quiet) + { + var result = await ExecuteQuietlyAsync(command, workingDirectory); + if (!result.Success) + { + Console.WriteLine(string.IsNullOrEmpty(operationName) + ? $"Command failed. See: {result.TempFilePathWithSize}" + : result.GetErrorSummary(operationName) + ); + Environment.Exit(1); + } + } + else + { + StartProcess(command, workingDirectory); + } + } + + /// + /// Executes a command in either quiet or verbose mode (synchronous wrapper). + /// In quiet mode: captures output and shows error summary on failure. + /// In verbose mode: shows output in real-time. + /// + public static void Run(string command, string? workingDirectory = null, string operationName = "", bool quiet = false) + { + RunAsync(command, workingDirectory, operationName, quiet).GetAwaiter().GetResult(); + } + + public static ProcessResult ExecuteQuietly( + string command, + string? workingDirectory = null, + params (string Name, string Value)[] environmentVariables + ) + { + return ExecuteQuietlyAsync(command, workingDirectory, environmentVariables).GetAwaiter().GetResult(); + } + + public static async Task ExecuteQuietlyAsync( + string command, + string? workingDirectory = null, + params (string Name, string Value)[] environmentVariables + ) + { + var processStartInfo = CreateProcessStartInfo(command, workingDirectory, true); + processStartInfo.RedirectStandardOutput = true; + processStartInfo.RedirectStandardError = true; + + foreach (var environmentVariable in environmentVariables) + { + processStartInfo.Environment[environmentVariable.Name] = environmentVariable.Value; + } + + using var process = Process.Start(processStartInfo)!; + + // Read stdout and stderr asynchronously to prevent deadlock + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + // Ensure async stream reads complete (WaitForExit can return before streams finish) + var results = await Task.WhenAll(stdoutTask, stderrTask); + + var stdout = results[0]; + var stderr = results[1]; + + // Save full output to temp file + var tempFile = Path.Combine(TempOutputDirectory, $"{Guid.NewGuid()}.log"); + var fullOutput = $"Command: {command}\nWorking Directory: {workingDirectory ?? "N/A"}\nExit Code: {process.ExitCode}\n\n=== STDOUT ===\n{stdout}\n\n=== STDERR ===\n{stderr}"; + await File.WriteAllTextAsync(tempFile, fullOutput); + + return new ProcessResult(process.ExitCode, stdout, stderr, tempFile); + } + public static string StartProcess( string command, string? solutionFolder = null, @@ -91,7 +205,7 @@ public static string StartProcess( bool throwOnError = false ) { - if (Configuration.VerboseLogging) + if (Configuration.TraceEnabled) { var escapedArguments = Markup.Escape(processStartInfo.Arguments); AnsiConsole.MarkupLine($"[cyan]{processStartInfo.FileName} {escapedArguments}[/]"); @@ -132,6 +246,19 @@ public static bool IsProcessRunning(string process) return Process.GetProcessesByName(process).Length > 0; } + public static string FormatFileSize(string filePath) + { + var fileInfo = new FileInfo(filePath); + var sizeInBytes = fileInfo.Length; + + return sizeInBytes switch + { + < 1024 => $"{sizeInBytes} bytes", + < 1024 * 1024 => $"{sizeInBytes / 1024.0:F1} KB", + _ => $"{sizeInBytes / (1024.0 * 1024):F1} MB" + }; + } + private static string? FindFullPathFromPath(string command) { string[] commandFormats = OperatingSystem.IsWindows() ? ["{0}.exe", "{0}.cmd"] : ["{0}"]; @@ -162,3 +289,38 @@ public class ProcessExecutionException(int exitCode, string message) { public int ExitCode { get; } = exitCode; } + +public record ProcessResult(int ExitCode, string StdOut, string StdErr, string TempFilePath) +{ + public bool Success => ExitCode == 0; + + public string CombinedOutput => $"{StdOut}\n{StdErr}"; + + public string TempFilePathWithSize => $"{TempFilePath} ({ProcessHelper.FormatFileSize(TempFilePath)})"; + + public string GetErrorSummary(string operation) + { + var errorLines = CombinedOutput + .Split('\n') + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToArray(); + + var outputBuilder = new StringBuilder(); + outputBuilder.Append(operation).AppendLine(" failed."); + outputBuilder.AppendLine(); + + foreach (var line in errorLines.Take(3)) + { + outputBuilder.Append(" ").AppendLine(line.Trim()); + } + + if (errorLines.Length > 3) + { + outputBuilder.Append(" ... and ").Append(errorLines.Length - 3).AppendLine(" more lines"); + outputBuilder.AppendLine(); + outputBuilder.Append("Full output: ").Append(TempFilePath).Append(" (").Append(ProcessHelper.FormatFileSize(TempFilePath)).Append(')'); + } + + return outputBuilder.ToString(); + } +} diff --git a/developer-cli/Utilities/SelfContainedSystemHelper.cs b/developer-cli/Utilities/SelfContainedSystemHelper.cs index 1094d6ccf2..a999053fa9 100644 --- a/developer-cli/Utilities/SelfContainedSystemHelper.cs +++ b/developer-cli/Utilities/SelfContainedSystemHelper.cs @@ -8,7 +8,7 @@ public static class SelfContainedSystemHelper public static string[] GetAvailableSelfContainedSystems() { return Directory.GetDirectories(Configuration.ApplicationFolder) - .Where(dir => HasRequiredFolders(dir)) + .Where(HasRequiredFolders) .Select(Path.GetFileName) .Where(name => name is not null) .Select(name => name!) @@ -40,4 +40,55 @@ public static string PromptForSelfContainedSystem(string[] availableSystems) .AddChoices(availableSystems) ); } + + public static FileInfo GetSolutionFile(string? selfContainedSystem) + { + if (selfContainedSystem is null) + { + // When no system is specified, use the root solution for faster single-pass processing + var slnxFiles = Directory.GetFiles(Configuration.ApplicationFolder, "*.slnx"); + + if (slnxFiles.Length == 0) + { + AnsiConsole.MarkupLine("[red]No root solution file (.slnx) found in application/[/]"); + AnsiConsole.MarkupLine("[yellow]Please ensure a .slnx file exists in the application folder, or specify a self-contained system using the -s flag.[/]"); + AnsiConsole.MarkupLine($"[yellow]Available systems: {string.Join(", ", GetAvailableSelfContainedSystems())}[/]"); + Environment.Exit(1); + } + + if (slnxFiles.Length > 1) + { + var fileNames = slnxFiles.Select(Path.GetFileName).ToArray(); + AnsiConsole.MarkupLine("[red]Multiple root solution files (.slnx) found in application/[/]"); + AnsiConsole.MarkupLine($"[yellow]Found: {string.Join(", ", fileNames)}[/]"); + AnsiConsole.MarkupLine("[yellow]Please ensure only one .slnx file exists in the application folder.[/]"); + Environment.Exit(1); + } + + return new FileInfo(slnxFiles[0]); + } + + var scsFolder = Path.Combine(Configuration.ApplicationFolder, selfContainedSystem); + if (!Directory.Exists(scsFolder)) + { + AnsiConsole.MarkupLine($"[red]Self-contained system '{selfContainedSystem}' not found in application/[/]"); + AnsiConsole.MarkupLine($"[yellow]Available systems: {string.Join(", ", GetAvailableSelfContainedSystems())}[/]"); + Environment.Exit(1); + } + + var slnfFiles = Directory.GetFiles(scsFolder, "*.slnf"); + if (slnfFiles.Length == 0) + { + AnsiConsole.MarkupLine($"[red]No .slnf file found in application/{selfContainedSystem}/[/]"); + Environment.Exit(1); + } + + if (slnfFiles.Length > 1) + { + AnsiConsole.MarkupLine($"[red]Multiple .slnf files found in application/{selfContainedSystem}/[/]"); + Environment.Exit(1); + } + + return new FileInfo(slnfFiles[0]); + } } diff --git a/developer-cli/Utilities/SolutionHelper.cs b/developer-cli/Utilities/SolutionHelper.cs deleted file mode 100644 index 516ba02771..0000000000 --- a/developer-cli/Utilities/SolutionHelper.cs +++ /dev/null @@ -1,80 +0,0 @@ -using PlatformPlatform.DeveloperCli.Installation; -using Spectre.Console; - -namespace PlatformPlatform.DeveloperCli.Utilities; - -public static class SolutionHelper -{ - public static FileInfo GetSolution(string? solutionName) - { - if (solutionName is not null) - { - var fileInfo = FindSolutionFile(solutionName); - if (fileInfo is not null) - { - return fileInfo; - } - - AnsiConsole.MarkupLine($"[red]ERROR:[/] Solution file [yellow]{solutionName}[/] not found."); - Environment.Exit(1); - } - - var solutionFiles = GetSolutionFiles(); - - if (solutionFiles.Count == 1) - { - solutionName = solutionFiles.Keys.Single(); - } - else if (solutionFiles.Count > 1) - { - var prompt = new SelectionPrompt() - .Title("Please select a solution") - .AddChoices(solutionFiles.Keys); - - solutionName = AnsiConsole.Prompt(prompt); - } - else - { - AnsiConsole.MarkupLine("[red]ERROR:[/] No solution files found."); - Environment.Exit(1); - } - - return new FileInfo(solutionFiles[solutionName]); - } - - private static FileInfo? FindSolutionFile(string solutionPath) - { - // Test if it exists as an exact path - if (File.Exists(solutionPath)) - { - return new FileInfo(solutionPath); - } - - // Test if it exists as a relative path to the application folder - var applicationFolderPath = Path.Combine(Configuration.ApplicationFolder, solutionPath); - if (File.Exists(applicationFolderPath)) - { - return new FileInfo(applicationFolderPath); - } - - // Test if it exists as a relative path to the source code folder - var sourceCodeFolderPath = Path.Combine(Configuration.SourceCodeFolder, solutionPath); - if (File.Exists(sourceCodeFolderPath)) - { - return new FileInfo(sourceCodeFolderPath); - } - - // Test if a file with that name exists in any subdirectory - var fileName = Path.GetFileName(solutionPath); - var matchingFiles = Directory.GetFiles(Configuration.ApplicationFolder, fileName, SearchOption.AllDirectories); - return matchingFiles.Length > 0 ? new FileInfo(matchingFiles[0]) : null; - } - - private static Dictionary GetSolutionFiles() - { - return Directory - .GetFiles(Configuration.ApplicationFolder, "*.slnx", SearchOption.AllDirectories) - .OrderBy(f => f) - .ToDictionary(s => new FileInfo(s).Name.Replace(".slnx", ""), s => s); - } -}