Conversation
…tart to config again
There was a problem hiding this comment.
Pull request overview
This PR adds significant new functionality to the Aspire Project Commander library, enabling projects to define their own commands and startup forms through JSON manifests. The changes introduce a declarative approach where projects can specify interactive configuration requirements via a projectcommander.json file, complementing the existing code-based command registration.
Changes:
- Added project-defined commands via JSON manifest with schema validation
- Implemented startup forms that block project execution until user provides configuration
- Updated package dependencies from Aspire 9.x/ASP.NET 9.x to 13.x/10.x versions
- Refactored DI registration to avoid anti-pattern of calling BuildServiceProvider during registration
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
| schemas/projectcommander-v1.schema.json | JSON schema defining manifest structure for commands and startup forms |
| Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs | Resource annotation tracking startup form state and completion |
| Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs | Model types for deserializing manifest JSON |
| Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs | Manifest parsing and conversion to Aspire InteractionInput types |
| Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs | Added WithProjectManifest method and startup form command registration |
| Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs | Hub methods for startup form lifecycle management |
| Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs | Extended interface with startup form support and command arguments |
| Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs | Implemented startup form handling and blocking until completion |
| Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs | Refactored DI registration to avoid BuildServiceProvider anti-pattern |
| Sample/DataGenerator/projectcommander.json | Example manifest demonstrating startup forms and parameterized commands |
| Sample/DataGenerator/Program.cs | Updated to use startup form data and handle command arguments |
| README.md | Comprehensive documentation of new manifest and startup form features |
| CHANGELOG.md | Detailed changelog documenting breaking changes and new features |
| Multiple .csproj files | Package version updates across the solution |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| break; | ||
| case "specify": | ||
| logger.LogInformation("Specify command received with args {Args}", string.Join(", ", args)); | ||
| period = TimeSpan.FromSeconds(int.Parse(args[0])); |
There was a problem hiding this comment.
Potential format exception if args[0] cannot be parsed to an integer. Consider using int.TryParse instead of int.Parse to handle invalid input gracefully and log an appropriate error message.
| period = TimeSpan.FromSeconds(int.Parse(args[0])); | |
| if (args.Length == 0) | |
| { | |
| logger.LogWarning("Specify command requires a numeric argument, but no arguments were provided."); | |
| break; | |
| } | |
| if (!int.TryParse(args[0], out var seconds)) | |
| { | |
| logger.LogWarning("Specify command argument '{Argument}' is not a valid integer number of seconds.", args[0]); | |
| break; | |
| } | |
| period = TimeSpan.FromSeconds(seconds); |
|
|
||
| if (result.Canceled) | ||
| { | ||
| return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." }; |
There was a problem hiding this comment.
The same cancellation handling issue exists here. When result.Canceled is true, the method returns Success = true with ErrorMessage = "User cancelled command." This is semantically confusing - cancellation should not be treated as a successful operation with an error message.
| return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." }; | |
| return new ExecuteCommandResult() { Success = false, ErrorMessage = "User cancelled command." }; |
| // Build form data dictionary | ||
| var formData = new Dictionary<string, string?>(); | ||
| for (var i = 0; i < inputs.Length; i++) | ||
| { | ||
| formData[inputs[i].Name] = result.Data[i].Value; | ||
| } | ||
|
|
||
| // Send form data to the project | ||
| var groupName = context.ResourceName; | ||
| await hubResource.Hub.Clients.Group(groupName).SendAsync( | ||
| "ReceiveStartupForm", | ||
| formData, | ||
| context.CancellationToken); |
There was a problem hiding this comment.
The startup form inputs are passed directly to the project without any server-side validation beyond what the client enforces. Consider adding validation logic on the AppHost side to ensure required fields are present and meet constraints (e.g., maxLength, valid choice options) before sending the data to the project, as client-side validation can be bypassed.
| /// </summary> | ||
| public event Func<string, IServiceProvider, Task> CommandReceived; | ||
| } No newline at end of file | ||
| public event Func<string, string[], IServiceProvider, Task> CommandReceived; |
There was a problem hiding this comment.
The CommandReceived event signature changed from (string, IServiceProvider, Task) to (string, string[], IServiceProvider, Task), adding a new args parameter in the middle. This is a breaking change that will require all existing event handlers to be updated. Consider documenting this breaking change prominently in the CHANGELOG and migration guide, and ensure the major version bump (2.0.0) is justified.
| public async Task<Dictionary<string, string?>?> WaitForStartupFormAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| // If no startup form is required, return immediately | ||
| if (!_isStartupFormRequired) | ||
| { | ||
| logger.LogDebug("No startup form required, continuing immediately"); | ||
| return null; | ||
| } | ||
|
|
||
| // If already completed, return the data | ||
| if (_isStartupFormCompleted && _startupFormData != null) | ||
| { | ||
| return _startupFormData; | ||
| } | ||
|
|
||
| logger.LogInformation("Waiting for startup form to be completed..."); | ||
|
|
||
| // Wait for the form to be completed | ||
| using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | ||
|
|
||
| var completedTask = await Task.WhenAny( | ||
| _startupFormCompletionSource.Task, | ||
| Task.Delay(Timeout.Infinite, cts.Token)); | ||
|
|
||
| if (completedTask == _startupFormCompletionSource.Task) | ||
| { | ||
| return await _startupFormCompletionSource.Task; | ||
| } | ||
|
|
||
| // Cancelled | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
The new startup form functionality lacks test coverage. Consider adding tests to verify: 1) Startup form data is correctly transmitted from AppHost to project, 2) WaitForStartupFormAsync blocks until form completion, 3) Form validation and error handling work correctly, 4) Resource name parsing in ProjectCommanderHub works with various formats. The existing test file only contains placeholder comments.
| // Wait for the form to be completed | ||
| using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | ||
|
|
||
| var completedTask = await Task.WhenAny( | ||
| _startupFormCompletionSource.Task, | ||
| Task.Delay(Timeout.Infinite, cts.Token)); | ||
|
|
||
| if (completedTask == _startupFormCompletionSource.Task) | ||
| { | ||
| return await _startupFormCompletionSource.Task; | ||
| } | ||
|
|
||
| // Cancelled | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| return null; |
There was a problem hiding this comment.
The WaitForStartupFormAsync method has a logical issue: if cancellation is requested, the code creates a linked token source and waits on Task.Delay(Timeout.Infinite), which will never complete unless cancelled. However, when cancelled, line 199 throws, but line 200 attempts to return null which is unreachable. Consider simplifying this logic to directly await the TaskCompletionSource.Task with the cancellation token, which will throw OperationCanceledException when cancelled.
| // Wait for the form to be completed | |
| using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | |
| var completedTask = await Task.WhenAny( | |
| _startupFormCompletionSource.Task, | |
| Task.Delay(Timeout.Infinite, cts.Token)); | |
| if (completedTask == _startupFormCompletionSource.Task) | |
| { | |
| return await _startupFormCompletionSource.Task; | |
| } | |
| // Cancelled | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| return null; | |
| // Wait for the form to be completed, honoring cancellation | |
| return await _startupFormCompletionSource.Task.WaitAsync(cancellationToken); |
| services.AddSingleton<AspireProjectCommanderClientWorker>(); | ||
| services.AddSingleton<IHostedService>(sp => sp.GetRequiredService<AspireProjectCommanderClientWorker>()); | ||
| services.AddSingleton<IAspireProjectCommanderClient>(sp => sp.GetRequiredService<AspireProjectCommanderClientWorker>()); |
There was a problem hiding this comment.
The refactored DI registration allows registering the service multiple times. If AddAspireProjectCommanderClient is called twice, it will register duplicate singleton services, which could lead to multiple instances of AspireProjectCommanderClientWorker being created. Consider checking if the service is already registered (e.g., using TryAddSingleton) or documenting that this method should only be called once.
| // Notify the hub of completion | ||
| try | ||
| { | ||
| await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken); |
There was a problem hiding this comment.
The startup form completion callback at line 102 passes _aspireResourceName which could be null (it's a nullable field). Add a null check before calling InvokeAsync or ensure _aspireResourceName is assigned before the hub connection handlers are registered.
| await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken); | |
| if (_aspireResourceName is not null) | |
| { | |
| await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken); | |
| } | |
| else | |
| { | |
| logger.LogWarning("Cannot notify hub of startup form completion because Aspire resource name is not set."); | |
| } |
| await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); | ||
|
|
||
| // Check if this resource has a startup form and notify the client | ||
| var baseResourceName = resourceName.Split('-')[0]; |
There was a problem hiding this comment.
The resource name parsing using Split('-')[0] is fragile and could fail if the resource name contains multiple hyphens or doesn't follow the expected format. Consider using a more robust parsing method, such as LastIndexOf('-') to get the suffix, or document the exact format expected. This affects the hub's ability to correctly identify resources with startup forms.
| period = TimeSpan.FromSeconds(int.Parse(args[0])); | ||
| logger.LogInformation("Period was set to {Period}", period); |
There was a problem hiding this comment.
Potential array index out of bounds exception if args array is empty when parsing args[0]. Add validation to check that args.Length > 0 before accessing args[0], or handle the IndexOutOfRangeException appropriately.
| period = TimeSpan.FromSeconds(int.Parse(args[0])); | |
| logger.LogInformation("Period was set to {Period}", period); | |
| if (args.Length > 0 && int.TryParse(args[0], out var seconds)) | |
| { | |
| period = TimeSpan.FromSeconds(seconds); | |
| logger.LogInformation("Period was set to {Period}", period); | |
| } | |
| else | |
| { | |
| logger.LogWarning("Specify command received without a valid numeric argument. Period remains {Period}", period); | |
| } |
No description provided.