From 62b167a368eac84ec3787c6b7f2e1822665a3651 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 29 Aug 2025 13:47:13 -0400 Subject: [PATCH 01/16] add aspirify bits --- Sample/DataGenerator/Program.cs | 11 +++-- Sample/ProjectCommander.AppHost/Program.cs | 6 ++- ...sourceBuilderProjectCommanderExtensions.cs | 46 +++++++++++++++++-- .../AspireProjectCommanderClientWorker.cs | 10 ++-- .../IAspireProjectCommanderClient.cs | 2 +- ...lectionAspireProjectCommanderExtensions.cs | 18 +++++--- 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/Sample/DataGenerator/Program.cs b/Sample/DataGenerator/Program.cs index 5844c08..009a118 100644 --- a/Sample/DataGenerator/Program.cs +++ b/Sample/DataGenerator/Program.cs @@ -36,17 +36,22 @@ await Task.Run(async () => { var period = TimeSpan.FromSeconds(1); - aspire.CommandReceived += (commandName, sp) => + aspire.CommandReceived += (commandName, args, sp) => { switch (commandName) { case "slow": period = TimeSpan.FromSeconds(1); - logger.LogInformation("Slow command received"); + logger.LogInformation("Slow command received with args {Args}", string.Join(", ", args)); break; case "fast": period = TimeSpan.FromMilliseconds(10); - logger.LogInformation("Fast command received"); + logger.LogInformation("Fast command received with args {Args}", string.Join(", ", args)); + break; + case "specify": + logger.LogInformation("Specify command received with args {Args}", string.Join(", ", args)); + period = TimeSpan.FromSeconds(int.Parse(args[0])); + logger.LogInformation("Period was set to {Period}", period); break; default: throw new NotSupportedException(commandName); diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 243a80b..934a7bf 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIREINTERACTION001 + using CommunityToolkit.Aspire.Hosting.ProjectCommander; var builder = DistributedApplication.CreateBuilder(args); @@ -21,7 +23,9 @@ .WaitFor(datahub) .WithProjectCommands( new("slow", "Go Slow"), - new("fast", "Go Fast")); + new("fast", "Go Fast"), + new ProjectCommand("specify", "Specify Delay", + new InteractionInput { Label = "period", InputType = InputType.Number })); builder.AddProject("consumer") .WithReference(commander) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 503c2ae..828901c 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIREINTERACTION001 + using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Hosting.ProjectCommander; using Microsoft.AspNetCore.SignalR; @@ -12,7 +14,7 @@ namespace Aspire.Hosting; /// The unique name of the command. This value is typically used as an identifier. /// The user-friendly name of the command, intended for display in UI or logs. /// Optional arguments to pass to the command. -public record ProjectCommand(string Name, string DisplayName, params string[] Arguments); +public record ProjectCommand(string Name, string DisplayName, params InteractionInput[] Arguments); /// /// Extension methods for configuring the Aspire Project Commander. @@ -41,6 +43,22 @@ public static IResourceBuilder WithProjectCommands( { builder.WithCommand(command.Name, command.DisplayName, async (context) => { + // Thank you LLMs for the idea, but + // // DO NOT DO THIS!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // // it might work in simple cases, but it will fail in complex ones + // // trust me, I've tried + // // and this is what LLMs are good for, telling you what NOT to do + // // because you are all wondering what the heck I'm talking about + // // lets run this and see what happens + // // because I know you are all thinking it + // // and I don't want to hear it + // i++; + // builder.WithCommand($"dynamic-command-{i}", command.DisplayName, async (context) => + // { + // return new ExecuteCommandResult() { Success = true }; + // }, + // new CommandOptions { }); + bool success = false; string errorMessage = string.Empty; @@ -49,8 +67,30 @@ public static IResourceBuilder WithProjectCommands( var model = context.ServiceProvider.GetRequiredService(); var hub = model.Resources.OfType().Single().Hub!; - var groupName = context.ResourceName; - await hub.Clients.Group(groupName).SendAsync("ReceiveCommand", command.Name, context.CancellationToken); + if (command.Arguments.Length > 0) + { + var interaction = context.ServiceProvider.GetRequiredService(); + var result = await interaction.PromptInputsAsync($"Arguments for {command.Name}", $"Arguments {command.Name}", command.Arguments, cancellationToken : context.CancellationToken); + + if (result.Canceled) + { + return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." }; + } + + var args = new string?[command.Arguments.Length]; + for (var i = 0; i < command.Arguments.Length; i++) + { + args[i] = result.Data[i].Value; + } + + var groupName = context.ResourceName; + await hub.Clients.Group(groupName).SendAsync("ReceiveCommand", command.Name, args, context.CancellationToken); + } + else + { + var groupName = context.ResourceName; + await hub.Clients.Group(groupName).SendAsync("ReceiveCommand", command.Name, Array.Empty(), context.CancellationToken); + } success = true; } diff --git a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs index f3478ec..8abb7dc 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -14,7 +14,7 @@ namespace CommunityToolkit.Aspire.ProjectCommander internal sealed class AspireProjectCommanderClientWorker(IConfiguration configuration, IServiceProvider serviceProvider, ILogger logger) : BackgroundService, IAspireProjectCommanderClient { - private readonly List> _commandHandlers = new(); + private readonly List> _commandHandlers = new(); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -34,16 +34,16 @@ await Task.Run(async () => .Build(); // Wire up a command handler - hub.On("ReceiveCommand", async (command) => + hub.On("ReceiveCommand", async (command, args) => { - logger.LogDebug("Received command: {CommandName}", command); + logger.LogDebug("Received command: {CommandName} {Args}", command, string.Join(", ", args)); // note: could be optimized to run in parallel foreach (var handler in _commandHandlers) { try { - await handler(command, serviceProvider); + await handler(command, args, serviceProvider); } catch (Exception ex) { @@ -68,7 +68,7 @@ await Task.Run(async () => }, stoppingToken); } - public event Func CommandReceived + public event Func CommandReceived { add => _commandHandlers.Add(value); remove => _commandHandlers.Remove(value); diff --git a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs index 599ba7d..9c81cf1 100644 --- a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs +++ b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs @@ -8,5 +8,5 @@ public interface IAspireProjectCommanderClient /// /// Occurs when a command is received. The name of the command is passed as an argument. /// - public event Func CommandReceived; + public event Func CommandReceived; } \ No newline at end of file diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 191cc01..8f6c755 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -16,14 +16,18 @@ public static class ServiceCollectionAspireProjectCommanderExtensions /// Returns the updated service collection. public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services) { - var sp = services.BuildServiceProvider(); + // var sp = services.BuildServiceProvider(); - if (sp.GetService() is null) - { - var worker = ActivatorUtilities.CreateInstance(sp); - services.AddSingleton(worker); - services.AddSingleton(worker); - } + // if (sp.GetService() is null) + // { + // var worker = ActivatorUtilities.CreateInstance(sp); + // services.AddSingleton(worker); + // services.AddSingleton(worker); + // } + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); return services; } From 351730de29dfbf38109325b948a5b35cc6e2976e Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 5 Feb 2026 11:28:45 -0500 Subject: [PATCH 02/16] first pass with fowler for aspirify show --- .aspire/settings.json | 3 +++ NuGet.config | 12 +++++++++++ .../ProjectCommander.Tests.csproj | 6 +++--- Sample/Consumer/Consumer.csproj | 2 +- Sample/DataGenerator/DataGenerator.csproj | 2 +- Sample/ProjectCommander.AppHost/Program.cs | 5 +++-- .../ProjectCommander.AppHost.csproj | 7 ++----- .../ProjectCommander.ServiceDefaults.csproj | 14 ++++++------- Sample/SpiraLog/SpiraLog.csproj | 6 +++--- ...vot.Aspire.Hosting.ProjectCommander.csproj | 6 +++--- .../ProjectCommanderHub.cs | 3 ++- .../IAspireProjectCommanderClient.cs | 21 ++++++++++++++++++- .../Nivot.Aspire.ProjectCommander.csproj | 6 +++--- 13 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 .aspire/settings.json create mode 100644 NuGet.config diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 0000000..fd5b590 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj" +} \ No newline at end of file diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..993d13d --- /dev/null +++ b/NuGet.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ProjectCommander.Tests/ProjectCommander.Tests.csproj b/ProjectCommander.Tests/ProjectCommander.Tests.csproj index aeca33b..ca40d6b 100644 --- a/ProjectCommander.Tests/ProjectCommander.Tests.csproj +++ b/ProjectCommander.Tests/ProjectCommander.Tests.csproj @@ -9,14 +9,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Sample/Consumer/Consumer.csproj b/Sample/Consumer/Consumer.csproj index 66f4392..7f4d8d7 100644 --- a/Sample/Consumer/Consumer.csproj +++ b/Sample/Consumer/Consumer.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/DataGenerator/DataGenerator.csproj b/Sample/DataGenerator/DataGenerator.csproj index 66f4392..7f4d8d7 100644 --- a/Sample/DataGenerator/DataGenerator.csproj +++ b/Sample/DataGenerator/DataGenerator.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 934a7bf..986c84c 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -24,11 +24,12 @@ .WithProjectCommands( new("slow", "Go Slow"), new("fast", "Go Fast"), - new ProjectCommand("specify", "Specify Delay", - new InteractionInput { Label = "period", InputType = InputType.Number })); + new ("specify", "Specify Delay\u2026", // ... with ellipsis + new InteractionInput { Name = "delay", Label = "period", InputType = InputType.Number })); builder.AddProject("consumer") .WithReference(commander) + .WaitFor(commander) .WithReference(client) .WaitFor(datahub); diff --git a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj index 312d6df..d135980 100644 --- a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj +++ b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj @@ -1,6 +1,4 @@ - - - + Exe @@ -13,8 +11,7 @@ - - + diff --git a/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj b/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj index 4473e3f..e9fc002 100644 --- a/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj +++ b/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/Sample/SpiraLog/SpiraLog.csproj b/Sample/SpiraLog/SpiraLog.csproj index c6f78f5..0f20f14 100644 --- a/Sample/SpiraLog/SpiraLog.csproj +++ b/Sample/SpiraLog/SpiraLog.csproj @@ -8,9 +8,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj index df83b10..8414adb 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj @@ -16,11 +16,11 @@ - - + + all runtime; build; native; contentfiles; analyzers - + diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index d786c60..0427ee7 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using JetBrains.Annotations; using Microsoft.AspNetCore.SignalR; @@ -19,7 +20,7 @@ internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService /// /// [UsedImplicitly] - public async Task Identify([ResourceName] string resourceName) + public async Task Identify([ResourceName] string resourceName) //, ProjectCommand[]? commands = null) { logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); diff --git a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs index 9c81cf1..80878c4 100644 --- a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs +++ b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs @@ -9,4 +9,23 @@ public interface IAspireProjectCommanderClient /// Occurs when a command is received. The name of the command is passed as an argument. /// public event Func CommandReceived; -} \ No newline at end of file +} + +/// +/// Provides extension methods for registering commands with an Aspire project commander client. +/// +public static class AspireProjectCommanderClientExtensions +{ + /// + /// Registers a command with the specified name. + /// + /// + /// + /// + + public static IAspireProjectCommanderClient RegisterProjectCommand(this IAspireProjectCommanderClient client, string commandName) + { + return client; + } +} + diff --git a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj index 5a401d9..35404a7 100644 --- a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj @@ -12,9 +12,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers From ff683a2156a4a19db2e7f5ba8ef0dc86ae38d0ed Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 5 Feb 2026 13:51:28 -0500 Subject: [PATCH 03/16] first pass at startup forms, commands and json project commander manifest support --- .gitignore | 3 + CHANGELOG.md | 113 ++++++++++ README.md | 178 +++++++++++++-- Sample/DataGenerator/Program.cs | 44 +++- Sample/DataGenerator/projectcommander.json | 66 ++++++ Sample/ProjectCommander.AppHost/Program.cs | 6 +- Sample/sequenceDiagram.mmd | 96 ++++++-- .../ManifestReader.cs | 93 ++++++++ .../ProjectCommandManifest.cs | 173 +++++++++++++++ .../ProjectCommanderHub.cs | 47 +++- ...sourceBuilderProjectCommanderExtensions.cs | 154 +++++++++++++ .../StartupFormAnnotation.cs | 40 ++++ .../AspireProjectCommanderClientWorker.cs | 148 ++++++++++++- .../IAspireProjectCommanderClient.cs | 24 ++ copilot.md | 41 ++++ schemas/projectcommander-v1.schema.json | 208 ++++++++++++++++++ 16 files changed, 1380 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Sample/DataGenerator/projectcommander.json create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs create mode 100644 copilot.md create mode 100644 schemas/projectcommander-v1.schema.json diff --git a/.gitignore b/.gitignore index 4bf4abb..f623ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from `dotnet new gitignore` +# claude agent detritus +tmpclaude-* + # dotenv files .env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf3c100 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +All notable changes to Aspire Project Commander will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +#### Project-Defined Commands via JSON Manifest + +Projects can now define their own commands using a `projectcommander.json` manifest file placed in the project root directory. This enables projects to be self-describing and portable, without requiring command definitions in the AppHost. + +**New extension method:** +- `WithProjectManifest()` - Reads commands and startup forms from the project's `projectcommander.json` file + +**Manifest features:** +- Define commands with name, display name, description, and icon +- Specify interactive inputs for commands (Text, SecretText, Choice, Boolean, Number) +- Define startup forms that must be completed before the project starts + +#### Startup Forms + +Projects can now require configuration before starting their main work via interactive startup forms. + +**New client interface members:** +- `WaitForStartupFormAsync(CancellationToken)` - Blocks until the startup form is completed by the user +- `IsStartupFormRequired` - Indicates if a startup form is configured for this project +- `IsStartupFormCompleted` - Indicates if the startup form has been submitted +- `StartupFormReceived` event - Fires when startup form data is received + +**How it works:** +1. Define a `startupForm` section in your `projectcommander.json` +2. Call `await commander.WaitForStartupFormAsync()` in your project's startup +3. The project waits for the user to click the Configure command in the dashboard +4. Once submitted, the form data is returned and the project continues + +#### Combining Manifest and Code Commands + +You can now use both `WithProjectManifest()` and `WithProjectCommands()` together. Commands from both sources are merged, with code-based commands taking precedence for duplicate names. + +### New Files + +| File | Purpose | +|------|---------| +| `ProjectCommandManifest.cs` | Manifest types for deserializing `projectcommander.json` | +| `ManifestReader.cs` | JSON parser and InputDefinition to InteractionInput converter | +| `StartupFormAnnotation.cs` | Resource annotation for tracking startup form state | + +### Modified Files + +| File | Changes | +|------|---------| +| `ResourceBuilderProjectCommanderExtensions.cs` | Added `WithProjectManifest()` and startup form command registration | +| `ProjectCommanderHub.cs` | Added startup form lifecycle methods | +| `IAspireProjectCommanderClient.cs` | Added startup form interface members | +| `AspireProjectCommanderClientWorker.cs` | Implemented startup form handling | + +### Example Manifest + +```json +{ + "version": "1.0", + "startupForm": { + "title": "Configure Data Generator", + "inputs": [ + { "name": "delay", "label": "Delay (seconds)", "inputType": "Number", "required": true } + ] + }, + "commands": [ + { "name": "slow", "displayName": "Go Slow" }, + { "name": "fast", "displayName": "Go Fast" } + ] +} +``` + +### Migration Guide + +**From code-based commands to manifest-based:** + +Before: +```csharp +builder.AddProject("datagenerator") + .WithReference(commander) + .WithProjectCommands( + new("slow", "Go Slow"), + new("fast", "Go Fast")); +``` + +After: +1. Create `projectcommander.json` in your project root +2. Update AppHost: +```csharp +builder.AddProject("datagenerator") + .WithReference(commander) + .WithProjectManifest(); +``` + +**Adding startup form support to existing projects:** + +1. Add `startupForm` section to your manifest +2. Call `await commander.WaitForStartupFormAsync(stoppingToken)` before your main work +3. Use the returned dictionary to configure your service + +--- + +## [1.1.0] - Previous Release + +- Initial release with code-based command definitions +- Remote resource log viewing via SpiraLog +- SignalR-based communication between AppHost and projects diff --git a/README.md b/README.md index 19fed78..ebb17bf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![icon](https://github.com/user-attachments/assets/a087a57f-63fe-43f6-ad72-e774eef86236) -Aspire Project commander is a set of packages that lets you send simple string commands from the dashboard directly to projects. +Aspire Project Commander is a set of packages that lets you send simple string commands from the dashboard directly to projects, and now supports **project-defined commands** via JSON manifests and **startup forms** for interactive project configuration. ## NuGet Packages @@ -13,18 +13,151 @@ Aspire Project commander is a set of packages that lets you send simple string c |Integration|`Nivot.Aspire.ProjectCommander`|![NuGet Version](https://img.shields.io/nuget/v/Nivot.Aspire.ProjectCommander)| |Hosting|`Nivot.Aspire.Hosting.ProjectCommander`|![NuGet Version](https://img.shields.io/nuget/v/Nivot.Aspire.Hosting.ProjectCommander)| +## Features + +- **Custom Project Commands** - Send commands from the Aspire Dashboard to running projects +- **Project-Defined Commands (New!)** - Projects can define their own commands via a `projectcommander.json` manifest +- **Startup Forms (New!)** - Projects can require configuration before starting via interactive forms +- **Interactive Inputs** - Commands can prompt for user input (Text, Number, Choice, Boolean, SecretText) +- **Remote Log Viewing** - Stream resource logs to a terminal window + ## Custom Resource Commands [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) allows adding [custom commands](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/custom-resource-commands) to any project in the dashboard but these commands are scoped to and handled in the AppHost itself. These are useful to send commands to APIs on running containers, such as performing a `FLUSHALL` on a Redis container to reset state. Ultimately, the `WithCommand` resource extension method requires you to interface with each target resource (e.g. `Executable`, `Container`, `Project`) independently, using code you write yourself. -## Custom Project Commands (New!) -This project and its associated NuGet packages allow you to send simple commands directly to `Project` type resources, that is to say, regular dotnet projects you're writing yourself. Register some simple string commands in the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) -- for example "start-messages", "stop-messages" -- using the [hosting](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/app-host-overview?tabs=docker) package `Nivot.Aspire.Hosting.ProjectCommander`, and then use the [integration](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/integrations-overview) package `Nivot.Aspire.ProjectCommander` to receive commands in your message generating project that you're using to dump data into an Azure Event Hubs emulator. +## Custom Project Commands +This project and its associated NuGet packages allow you to send simple commands directly to `Project` type resources, that is to say, regular dotnet projects you're writing yourself. Register some simple string commands in the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) -- for example "start-messages", "stop-messages" -- using the [hosting](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/app-host-overview?tabs=docker) package `Nivot.Aspire.Hosting.ProjectCommander`, and then use the [integration](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/integrations-overview) package `Nivot.Aspire.ProjectCommander` to receive commands in your message generating project that you're using to dump data into an Azure Event Hubs emulator. + +## Project-Defined Commands (New!) + +Instead of defining commands in the AppHost, projects can now define their own commands using a `projectcommander.json` manifest file. This allows projects to be self-describing and portable. + +### Manifest File: `projectcommander.json` + +Place this file in your project root (next to the `.csproj` file): + +```json +{ + "$schema": "https://raw.githubusercontent.com/oising/AspireProjectCommander/main/schemas/projectcommander-v1.schema.json", + "version": "1.0", + "startupForm": { + "title": "Configure Data Generator", + "description": "Please configure the data generator settings before starting.", + "inputs": [ + { + "name": "initialDelay", + "label": "Initial Delay (seconds)", + "inputType": "Number", + "required": true + }, + { + "name": "mode", + "label": "Generation Mode", + "inputType": "Choice", + "required": true, + "options": ["Continuous", "Burst", "On Demand"] + } + ] + }, + "commands": [ + { + "name": "slow", + "displayName": "Go Slow", + "iconName": "Clock" + }, + { + "name": "fast", + "displayName": "Go Fast", + "iconName": "FastForward" + }, + { + "name": "specify", + "displayName": "Specify Delay...", + "inputs": [ + { + "name": "delay", + "label": "Delay (seconds)", + "inputType": "Number", + "required": true + } + ] + } + ] +} +``` + +### Supported Input Types + +| Type | Description | +|------|-------------| +| `Text` | Single-line text input | +| `SecretText` | Masked password-style input | +| `Choice` | Selection from predefined options | +| `Boolean` | True/false toggle | +| `Number` | Numeric value entry | -## Remote resource log viewing (New!) -Some people may prefer to stream resource logs in a terminal window. See the `SpiraLog` sample in the source. +### Using the Manifest in AppHost -## Example +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var commander = builder.AddAspireProjectCommander(); -See the `Sample` folder for an Aspire example that allows you to signal a data generator project that is writing messages into an emulator instance of Azure Event Hubs. +builder.AddProject("datagenerator") + .WithReference(commander) + .WaitFor(commander) + .WithProjectManifest(); // Reads commands from projectcommander.json + +builder.Build().Run(); +``` + +### Handling Startup Forms in Projects + +```csharp +public sealed class DataGeneratorWorker( + IAspireProjectCommanderClient commander, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait for startup form to be completed (blocks until user submits) + var config = await commander.WaitForStartupFormAsync(stoppingToken); + + if (config != null) + { + var delay = int.Parse(config["initialDelay"] ?? "1"); + var mode = config["mode"]; + logger.LogInformation("Starting with delay={Delay}, mode={Mode}", delay, mode); + } + + // Register command handlers + commander.CommandReceived += (cmd, args, sp) => + { + switch (cmd) + { + case "slow": /* handle */ break; + case "fast": /* handle */ break; + case "specify": + var seconds = int.Parse(args[0]); + break; + } + return Task.CompletedTask; + }; + + // Main work loop + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1000, stoppingToken); + } + } +} +``` + +## Remote Resource Log Viewing +Some people may prefer to stream resource logs in a terminal window. See the `SpiraLog` sample in the source. + +## Code-Based Commands (Original Approach) + +You can still define commands directly in the AppHost using `WithProjectCommands`: ### AppHost Hosting @@ -34,11 +167,15 @@ var builder = DistributedApplication.CreateBuilder(args); var commander = builder.AddAspireProjectCommander(); builder.AddProject("eventhub-datagenerator") - // provides commander signalr hub connectionstring to integration + // provides commander signalr hub connectionstring to integration .WithReference(commander) - // array of simple tuples with the command string and a display value for the dashbaord - .WithProjectCommands((Name: "slow", DisplayName: "Go Slow"), ("fast", "Go Fast")) - // wait for commander signalr hub to be ready + // array of simple tuples with the command string and a display value for the dashboard + .WithProjectCommands( + new("slow", "Go Slow"), + new("fast", "Go Fast"), + new("specify", "Specify Delay...", + new InteractionInput { Name = "delay", Label = "period", InputType = InputType.Number })) + // wait for commander signalr hub to be ready .WaitFor(commander); var app = builder.Build(); @@ -62,13 +199,13 @@ builder.Services.AddHostedService(); // background service with DI IAspireProjectCommanderClient interface that allows registering an async handler public sealed class MyProjectCommands(IAspireProjectCommanderClient commander, ILogger logger) : BackgroundService -{ +{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Run(async () => { // add a handler that will receive commands - commander.CommandReceived += (string command, IServiceProvider sp) => + commander.CommandReceived += (string command, string[] args, IServiceProvider sp) => { // grab a service, call a method, set an option, signal a cancellation token etc... logger.LogInformation("Received command: {CommandName}", command); @@ -82,3 +219,18 @@ public sealed class MyProjectCommands(IAspireProjectCommanderClient commander, I } } ``` + +## Combining Manifest and Code Commands + +You can use both `WithProjectManifest()` and `WithProjectCommands()` together - the commands will be merged: + +```csharp +builder.AddProject("datagenerator") + .WithReference(commander) + .WithProjectManifest() // Commands from manifest + .WithProjectCommands(new("extra", "Extra Command")); // Additional code-defined command +``` + +## Sample + +See the `Sample` folder for an Aspire example that allows you to signal a data generator project that is writing messages into an emulator instance of Azure Event Hubs. diff --git a/Sample/DataGenerator/Program.cs b/Sample/DataGenerator/Program.cs index 009a118..bf4bccf 100644 --- a/Sample/DataGenerator/Program.cs +++ b/Sample/DataGenerator/Program.cs @@ -19,6 +19,8 @@ internal sealed class DataGeneratorWorker(IAspireProjectCommanderClient aspire, EventHubProducerClient producer, ILogger logger) : BackgroundService { + private bool _isPaused; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var json = """ @@ -32,10 +34,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) """; logger.LogInformation("Data generator worker started"); - await Task.Run(async () => + // Wait for startup form to be completed (if required) + var startupConfig = await aspire.WaitForStartupFormAsync(stoppingToken); + + // Apply startup configuration + var period = TimeSpan.FromSeconds(1); + if (startupConfig != null) { - var period = TimeSpan.FromSeconds(1); + if (startupConfig.TryGetValue("initialDelay", out var delayStr) && int.TryParse(delayStr, out var delay)) + { + period = TimeSpan.FromSeconds(delay); + logger.LogInformation("Initial delay set to {Delay} seconds from startup form", delay); + } + + if (startupConfig.TryGetValue("mode", out var mode)) + { + logger.LogInformation("Generation mode set to: {Mode}", mode); + _isPaused = mode == "On Demand"; + } + } + await Task.Run(async () => + { aspire.CommandReceived += (commandName, args, sp) => { switch (commandName) @@ -53,8 +73,17 @@ await Task.Run(async () => period = TimeSpan.FromSeconds(int.Parse(args[0])); logger.LogInformation("Period was set to {Period}", period); break; + case "pause": + _isPaused = true; + logger.LogInformation("Data generation paused"); + break; + case "resume": + _isPaused = false; + logger.LogInformation("Data generation resumed"); + break; default: - throw new NotSupportedException(commandName); + logger.LogWarning("Unknown command received: {CommandName}", commandName); + break; } return Task.CompletedTask; @@ -65,9 +94,12 @@ await Task.Run(async () => { await Task.Delay(period, stoppingToken); - await producer.SendAsync([ - new EventData( - Encoding.UTF8.GetBytes(json))], stoppingToken); + if (!_isPaused) + { + await producer.SendAsync([ + new EventData( + Encoding.UTF8.GetBytes(json))], stoppingToken); + } } }, stoppingToken); diff --git a/Sample/DataGenerator/projectcommander.json b/Sample/DataGenerator/projectcommander.json new file mode 100644 index 0000000..d8b2db2 --- /dev/null +++ b/Sample/DataGenerator/projectcommander.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://raw.githubusercontent.com/oising/AspireProjectCommander/main/schemas/projectcommander-v1.schema.json", + "version": "1.0", + "startupForm": { + "title": "Configure Data Generator", + "description": "Please configure the data generator settings before starting.", + "inputs": [ + { + "name": "initialDelay", + "label": "Initial Delay (seconds)", + "description": "The delay between generated events at startup", + "inputType": "Number", + "required": true, + "placeholder": "1" + }, + { + "name": "mode", + "label": "Generation Mode", + "description": "Select how data should be generated", + "inputType": "Choice", + "required": true, + "options": ["Continuous", "Burst", "On Demand"] + } + ] + }, + "commands": [ + { + "name": "slow", + "displayName": "Go Slow", + "description": "Set generation rate to slow (1 event per second)", + "iconName": "Clock" + }, + { + "name": "fast", + "displayName": "Go Fast", + "description": "Set generation rate to fast (100 events per second)", + "iconName": "FastForward" + }, + { + "name": "specify", + "displayName": "Specify Delay\u2026", + "description": "Set a custom delay between generated events", + "iconName": "Timer", + "inputs": [ + { + "name": "delay", + "label": "Delay (seconds)", + "inputType": "Number", + "required": true + } + ] + }, + { + "name": "pause", + "displayName": "Pause", + "description": "Pause data generation", + "iconName": "Pause" + }, + { + "name": "resume", + "displayName": "Resume", + "description": "Resume data generation", + "iconName": "Play" + } + ] +} diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 986c84c..78029a2 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -21,11 +21,7 @@ .WithReference(commander) .WaitFor(commander) .WaitFor(datahub) - .WithProjectCommands( - new("slow", "Go Slow"), - new("fast", "Go Fast"), - new ("specify", "Specify Delay\u2026", // ... with ellipsis - new InteractionInput { Name = "delay", Label = "period", InputType = InputType.Number })); + .WithProjectManifest(); // Reads commands and startup form from projectcommander.json builder.AddProject("consumer") .WithReference(commander) diff --git a/Sample/sequenceDiagram.mmd b/Sample/sequenceDiagram.mmd index 7b825a8..8b1ca34 100644 --- a/Sample/sequenceDiagram.mmd +++ b/Sample/sequenceDiagram.mmd @@ -1,36 +1,86 @@ sequenceDiagram participant AppHost as Aspire AppHost participant Hub as ProjectCommanderHub
(SignalR Server) + participant Dashboard as Aspire Dashboard participant DataGen as DataGenerator
(AspireProjectCommanderClientWorker) participant Worker as DataGeneratorWorker
(BackgroundService) - Note over AppHost,Worker: Application Startup - AppHost->>Hub: Start SignalR Hub on localhost + Note over AppHost,Worker: Application Startup - Manifest Processing + + AppHost->>AppHost: WithProjectManifest() called + AppHost->>AppHost: Read projectcommander.json
from project directory + + alt Manifest has StartupForm + AppHost->>AppHost: Create StartupFormAnnotation + AppHost->>AppHost: Set PROJECTCOMMANDER_STARTUP_FORM_REQUIRED=true + AppHost->>AppHost: Register "Configure" command + end + + AppHost->>AppHost: Register commands from manifest
via WithProjectCommands() + + AppHost->>Hub: Start SignalR Hub on localhost:27960 Hub-->>AppHost: Hub started successfully - + + Note over AppHost,Worker: Project Connection + + DataGen->>DataGen: Check PROJECTCOMMANDER_STARTUP_FORM_REQUIRED env var DataGen->>Hub: Connect to SignalR Hub Note over DataGen: Using connection string from
'project-commander' config - + DataGen->>Hub: InvokeAsync("Identify", resourceName) Note over DataGen: resourceName = "datagenerator-{suffix}" Hub->>Hub: Groups.AddToGroupAsync(connectionId, resourceName) + + alt Resource has StartupFormAnnotation + Hub->>DataGen: SendAsync("StartupFormRequired", title) + Note over DataGen: Client knows startup form is pending + end + Hub-->>DataGen: Connection established and grouped - + DataGen->>DataGen: Register "ReceiveCommand" handler - Note over DataGen: hub.On("ReceiveCommand", handler) - + DataGen->>DataGen: Register "ReceiveStartupForm" handler + Note over DataGen: hub.On("ReceiveCommand", handler)
hub.On("ReceiveStartupForm", handler) + + Note over AppHost,Worker: Startup Form Flow (if configured) + + Worker->>DataGen: WaitForStartupFormAsync(cancellationToken) + Note over Worker: Worker blocks until
startup form is completed + + Dashboard->>AppHost: User clicks "Configure Data Generator"
command in dashboard + + AppHost->>Dashboard: IInteractionService.PromptInputsAsync()
Show form with inputs + Dashboard-->>AppHost: User submits form data + + AppHost->>Hub: hub.Clients.Group(resourceName)
.SendAsync("ReceiveStartupForm", formData) + + Hub->>DataGen: ReceiveStartupForm(formData) + DataGen->>DataGen: Invoke StartupFormReceived handlers + DataGen->>Hub: InvokeAsync("StartupFormCompleted",
resourceName, success) + + DataGen->>Worker: Complete WaitForStartupFormAsync
with form data dictionary + + Worker->>Worker: Apply startup configuration + Note over Worker: Set initial delay, mode, etc.
from form data + Note over AppHost,Worker: Command Execution Flow - AppHost->>AppHost: User clicks "Go Slow" or "Go Fast"
in Aspire Dashboard - - AppHost->>Hub: Execute command via
WithCommand callback + + Dashboard->>AppHost: User clicks command
in Aspire Dashboard + + AppHost->>AppHost: Execute command via
WithCommand callback Note over AppHost: Resolves ProjectCommanderHubResource
and gets IHubContext - - Hub->>DataGen: Clients.Group(resourceName)
.SendAsync("ReceiveCommand", commandName) - Note over Hub: commandName = "slow" or "fast" - + + alt Command has inputs (e.g., "Specify Delay...") + AppHost->>Dashboard: IInteractionService.PromptInputsAsync() + Dashboard-->>AppHost: User enters values + end + + Hub->>DataGen: Clients.Group(resourceName)
.SendAsync("ReceiveCommand", name, args[]) + Note over Hub: commandName + arguments array + DataGen->>DataGen: Trigger registered handler - DataGen->>Worker: Fire CommandReceived event
with command name - + DataGen->>Worker: Fire CommandReceived event
with command name and args + Worker->>Worker: Process command alt Command is "slow" Worker->>Worker: Set period = 1 second @@ -38,6 +88,16 @@ sequenceDiagram else Command is "fast" Worker->>Worker: Set period = 10 milliseconds Worker->>Worker: Log "Fast command received" + else Command is "specify" + Worker->>Worker: Parse delay from args[0] + Worker->>Worker: Set period = delay seconds + Worker->>Worker: Log "Period was set to {period}" + else Command is "pause" + Worker->>Worker: Set _isPaused = true + Worker->>Worker: Log "Data generation paused" + else Command is "resume" + Worker->>Worker: Set _isPaused = false + Worker->>Worker: Log "Data generation resumed" end - - Note over Worker: Worker continues data generation
with new timing period \ No newline at end of file + + Note over Worker: Worker continues data generation
with updated settings diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs new file mode 100644 index 0000000..6a56e59 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs @@ -0,0 +1,93 @@ +#pragma warning disable ASPIREINTERACTION001 + +using System.Text.Json; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Reads and parses project command manifest files. +/// +internal static class ManifestReader +{ + /// + /// The expected manifest file name in the project directory. + /// + public const string ManifestFileName = "projectcommander.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// Attempts to read a project command manifest from the specified project directory. + /// + /// The directory containing the project. + /// The parsed manifest, or null if no manifest file exists. + public static ProjectCommandManifest? ReadManifest(string projectDirectory) + { + var manifestPath = Path.Combine(projectDirectory, ManifestFileName); + + if (!File.Exists(manifestPath)) + { + return null; + } + + var json = File.ReadAllText(manifestPath); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + /// + /// Converts an InputDefinition from the manifest to an Aspire InteractionInput. + /// + /// The input definition from the manifest. + /// An Aspire InteractionInput configured according to the definition. + public static InteractionInput ToInteractionInput(InputDefinition definition) + { + var input = new InteractionInput + { + Name = definition.Name, + Label = definition.Label ?? definition.Name, + Description = definition.Description, + InputType = ParseInputType(definition.InputType), + Required = definition.Required, + Placeholder = definition.Placeholder, + MaxLength = definition.MaxLength, + AllowCustomChoice = definition.AllowCustomChoice, + EnableDescriptionMarkdown = definition.EnableDescriptionMarkdown + }; + + // Set options for Choice input type + if (definition.Options is { Count: > 0 }) + { + // Convert string options to KeyValuePair where key and value are the same + input.Options = definition.Options + .Select(o => new KeyValuePair(o, o)) + .ToList(); + } + + return input; + } + + /// + /// Parses an input type string from the manifest to the Aspire InputType enum. + /// + /// The input type string (e.g., "Text", "Number"). + /// The corresponding InputType enum value. + /// Thrown when the input type string is not recognized. + private static InputType ParseInputType(string inputTypeString) + { + return inputTypeString.ToLowerInvariant() switch + { + "text" => InputType.Text, + "secrettext" => InputType.SecretText, + "choice" => InputType.Choice, + "boolean" => InputType.Boolean, + "number" => InputType.Number, + _ => throw new ArgumentException($"Unknown input type: {inputTypeString}. Valid types are: Text, SecretText, Choice, Boolean, Number.", nameof(inputTypeString)) + }; + } +} diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs new file mode 100644 index 0000000..2af6f42 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs @@ -0,0 +1,173 @@ +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Represents a project command manifest loaded from projectcommander.json. +/// This manifest defines startup forms and commands that projects can surface in the Aspire dashboard. +/// +public sealed class ProjectCommandManifest +{ + /// + /// The version of the manifest schema. Currently only "1.0" is supported. + /// + [JsonPropertyName("version")] + public string Version { get; set; } = "1.0"; + + /// + /// Optional startup form that must be filled out before the project starts its main work. + /// + [JsonPropertyName("startupForm")] + public StartupFormDefinition? StartupForm { get; set; } + + /// + /// Commands that should be added to the Aspire dashboard for this project. + /// + [JsonPropertyName("commands")] + public List Commands { get; set; } = []; +} + +/// +/// Defines a startup form that blocks project execution until filled out by the developer. +/// +public sealed class StartupFormDefinition +{ + /// + /// The title of the startup form dialog. + /// + [JsonPropertyName("title")] + public required string Title { get; set; } + + /// + /// Optional description text for the startup form. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The input fields for the startup form. + /// + [JsonPropertyName("inputs")] + public List Inputs { get; set; } = []; +} + +/// +/// Defines a command that appears in the Aspire dashboard for this project. +/// +public sealed class CommandDefinition +{ + /// + /// The unique identifier for the command. Must be lowercase alphanumeric with hyphens. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// The display name shown in the Aspire dashboard. + /// + [JsonPropertyName("displayName")] + public required string DisplayName { get; set; } + + /// + /// Optional description of what the command does. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The icon name to display. Uses Fluent UI icon names. + /// + [JsonPropertyName("iconName")] + public string? IconName { get; set; } + + /// + /// The icon variant: "Regular" or "Filled". + /// + [JsonPropertyName("iconVariant")] + public string? IconVariant { get; set; } + + /// + /// Optional confirmation message to show before executing the command. + /// + [JsonPropertyName("confirmationMessage")] + public string? ConfirmationMessage { get; set; } + + /// + /// Whether the command should be highlighted in the dashboard. + /// + [JsonPropertyName("isHighlighted")] + public bool IsHighlighted { get; set; } + + /// + /// Optional input fields to prompt for when the command is executed. + /// + [JsonPropertyName("inputs")] + public List Inputs { get; set; } = []; +} + +/// +/// Defines an input field for a command or startup form. +/// Maps to Aspire's InteractionInput type. +/// +public sealed class InputDefinition +{ + /// + /// The unique name of the input field. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// The label displayed to the user. + /// + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// Optional description or help text for the input. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The type of input: "Text", "SecretText", "Choice", "Boolean", or "Number". + /// + [JsonPropertyName("inputType")] + public required string InputType { get; set; } + + /// + /// Whether this input is required. + /// + [JsonPropertyName("required")] + public bool Required { get; set; } + + /// + /// Placeholder text for the input field. + /// + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } + + /// + /// Maximum length for text inputs. + /// + [JsonPropertyName("maxLength")] + public int? MaxLength { get; set; } + + /// + /// Options for Choice input types. + /// + [JsonPropertyName("options")] + public List? Options { get; set; } + + /// + /// Whether custom choices are allowed (for Choice input type). + /// + [JsonPropertyName("allowCustomChoice")] + public bool AllowCustomChoice { get; set; } + + /// + /// Whether the description supports Markdown rendering. + /// + [JsonPropertyName("enableDescriptionMarkdown")] + public bool EnableDescriptionMarkdown { get; set; } +} diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 0427ee7..1a6e4b7 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -16,8 +16,9 @@ internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService { /// /// Identifies the connecting client by adding it to a group named after the resource. + /// Also checks if the resource has a startup form and notifies the client. /// - /// + /// The resource name (e.g., "datagenerator-abc123"). /// [UsedImplicitly] public async Task Identify([ResourceName] string resourceName) //, ProjectCommand[]? commands = null) @@ -25,6 +26,50 @@ public async Task Identify([ResourceName] string resourceName) //, ProjectComman logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); + + // Check if this resource has a startup form and notify the client + var baseResourceName = resourceName.Split('-')[0]; + var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); + + if (resource != null) + { + var startupFormAnnotation = resource.Annotations.OfType().FirstOrDefault(); + if (startupFormAnnotation != null && !startupFormAnnotation.IsCompleted) + { + // Notify client that a startup form is required + await Clients.Caller.SendAsync("StartupFormRequired", startupFormAnnotation.Form.Title); + logger.LogInformation("{ResourceName} requires startup form: {Title}", resourceName, startupFormAnnotation.Form.Title); + } + } + } + + /// + /// Called by the project to signal that it has received and validated startup form data. + /// + /// The resource name. + /// Whether the form was validated successfully. + /// Optional error message if validation failed. + [UsedImplicitly] + public async Task StartupFormCompleted([ResourceName] string resourceName, bool success, string? errorMessage = null) + { + logger.LogInformation("{ResourceName} startup form completed: Success={Success}", resourceName, success); + + // Find the resource and update the annotation + var baseResourceName = resourceName.Split('-')[0]; + var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); + + if (resource != null) + { + var annotation = resource.Annotations.OfType().FirstOrDefault(); + if (annotation != null) + { + annotation.IsCompleted = success; + annotation.ErrorMessage = success ? null : errorMessage; + } + } + + // Notify dashboard/orchestrator that startup is complete + await Clients.All.SendAsync("StartupFormStatusChanged", resourceName, success, errorMessage); } /// diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 828901c..9026447 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -21,6 +21,160 @@ public record ProjectCommand(string Name, string DisplayName, params Interaction /// public static class ResourceBuilderProjectCommanderExtensions { + /// + /// Registers project commands from a projectcommander.json manifest file located in the project directory. + /// If no manifest file exists, no commands are registered. + /// This method can be combined with to add additional commands. + /// + /// The type of project resource. + /// The resource builder. + /// The resource builder for chaining. + public static IResourceBuilder WithProjectManifest(this IResourceBuilder builder) + where T : ProjectResource + { + var projectPath = GetProjectDirectory(builder.Resource); + + var manifest = ManifestReader.ReadManifest(projectPath); + + if (manifest == null) + { + // No manifest found, nothing to register + return builder; + } + + // Store startup form in annotation if present and register the configure command + if (manifest.StartupForm != null) + { + var startupFormAnnotation = new StartupFormAnnotation(manifest.StartupForm); + builder.WithAnnotation(startupFormAnnotation); + + // Add environment variable so the client knows it needs to wait for startup form + builder.WithEnvironment("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED", "true"); + + // Add a "Configure" command to trigger the startup form prompt + RegisterStartupFormCommand(builder, startupFormAnnotation); + } + + // Register commands from manifest + if (manifest.Commands.Count > 0) + { + var projectCommands = manifest.Commands + .Select(c => new ProjectCommand( + c.Name, + c.DisplayName, + c.Inputs.Select(ManifestReader.ToInteractionInput).ToArray())) + .ToArray(); + + // Use the existing WithProjectCommands method to register + return builder.WithProjectCommands(projectCommands); + } + + return builder; + } + + /// + /// Registers the startup form command for a resource with a startup form. + /// + private static void RegisterStartupFormCommand(IResourceBuilder builder, StartupFormAnnotation annotation) + where T : ProjectResource + { + var form = annotation.Form; + var inputs = form.Inputs.Select(ManifestReader.ToInteractionInput).ToArray(); + + builder.WithCommand( + name: "projectcommander-configure", + displayName: form.Title, + executeCommand: async (context) => + { + try + { + var model = context.ServiceProvider.GetRequiredService(); + var hubResource = model.Resources.OfType().SingleOrDefault(); + + if (hubResource?.Hub == null) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = "Project Commander hub is not running." + }; + } + + var interaction = context.ServiceProvider.GetRequiredService(); + + // Show the startup form prompt + var result = await interaction.PromptInputsAsync( + form.Title, + form.Description ?? "Please configure the following settings:", + inputs, + cancellationToken: context.CancellationToken); + + if (result.Canceled) + { + return new ExecuteCommandResult + { + Success = true, + ErrorMessage = "Configuration cancelled." + }; + } + + // Build form data dictionary + var formData = new Dictionary(); + 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); + + // Update annotation + annotation.FormData = formData; + annotation.IsCompleted = true; + + return new ExecuteCommandResult { Success = true }; + } + catch (Exception ex) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = ex.Message + }; + } + }, + new CommandOptions + { + IconName = "Settings", + IconVariant = IconVariant.Regular, + IsHighlighted = true + }); + } + + /// + /// Gets the project directory from a ProjectResource by reading its annotations. + /// + private static string GetProjectDirectory(ProjectResource resource) + { + // ProjectResource has IProjectMetadata annotation that contains the project path + var metadata = resource.Annotations.OfType().FirstOrDefault(); + + if (metadata == null) + { + throw new InvalidOperationException( + $"Resource '{resource.Name}' does not have project metadata. " + + "Ensure WithProjectManifest is called on a ProjectResource."); + } + + return Path.GetDirectoryName(metadata.ProjectPath) + ?? throw new InvalidOperationException( + $"Could not determine project directory from path: {metadata.ProjectPath}"); + } + /// /// Adds project commands to a project resource. /// diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs new file mode 100644 index 0000000..a601f71 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs @@ -0,0 +1,40 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Annotation that stores startup form configuration for a project resource. +/// When present, the project will be held in a "Waiting for Configuration" state +/// until the startup form is completed by the developer. +/// +public sealed class StartupFormAnnotation : IResourceAnnotation +{ + /// + /// Creates a new StartupFormAnnotation with the specified form definition. + /// + /// The startup form definition from the manifest. + public StartupFormAnnotation(StartupFormDefinition form) + { + Form = form ?? throw new ArgumentNullException(nameof(form)); + } + + /// + /// The startup form definition. + /// + public StartupFormDefinition Form { get; } + + /// + /// Whether the startup form has been completed by the user. + /// + public bool IsCompleted { get; set; } + + /// + /// The form data submitted by the user, keyed by input name. + /// + public Dictionary FormData { get; set; } = new(); + + /// + /// Optional error message if the form submission failed validation. + /// + public string? ErrorMessage { get; set; } +} diff --git a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs index 8abb7dc..c804506 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -15,30 +15,45 @@ internal sealed class AspireProjectCommanderClientWorker(IConfiguration configur : BackgroundService, IAspireProjectCommanderClient { private readonly List> _commandHandlers = new(); + private readonly List, IServiceProvider, Task>> _startupFormHandlers = new(); + private readonly TaskCompletionSource> _startupFormCompletionSource = new(); + + private HubConnection? _hub; + private string? _aspireResourceName; + private bool _isStartupFormRequired; + private bool _isStartupFormCompleted; + private Dictionary? _startupFormData; + + /// + public bool IsStartupFormRequired => _isStartupFormRequired; + + /// + public bool IsStartupFormCompleted => _isStartupFormCompleted; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Run(async () => { - // TODO: maybe hardcode to a wellknown value, i.e. a unique guid? + // Check if startup form is required via environment variable + _isStartupFormRequired = Environment.GetEnvironmentVariable("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED") == "true"; + var connectionString = configuration.GetConnectionString("project-commander"); - + if (connectionString == null) { throw new InvalidOperationException("Connection string 'project-commander' not found"); } - var hub = new HubConnectionBuilder() + _hub = new HubConnectionBuilder() .WithUrl(connectionString) .WithAutomaticReconnect() .Build(); - // Wire up a command handler - hub.On("ReceiveCommand", async (command, args) => + // Wire up command handler + _hub.On("ReceiveCommand", async (command, args) => { logger.LogDebug("Received command: {CommandName} {Args}", command, string.Join(", ", args)); - // note: could be optimized to run in parallel foreach (var handler in _commandHandlers) { try @@ -52,15 +67,75 @@ await Task.Run(async () => } }); - await hub.StartAsync(stoppingToken); + // Wire up startup form handler + _hub.On>("ReceiveStartupForm", async (formData) => + { + logger.LogInformation("Received startup form data with {Count} fields", formData.Count); + + bool success = true; + string? errorMessage = null; + + // Invoke all registered handlers + foreach (var handler in _startupFormHandlers) + { + try + { + var handlerResult = await handler(formData, serviceProvider); + if (!handlerResult) + { + success = false; + break; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing startup form"); + success = false; + errorMessage = ex.Message; + break; + } + } + + // Notify the hub of completion + try + { + await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error notifying hub of startup form completion"); + } + + if (success) + { + _isStartupFormCompleted = true; + _startupFormData = formData; + _startupFormCompletionSource.TrySetResult(formData); + logger.LogInformation("Startup form completed successfully"); + } + else + { + logger.LogWarning("Startup form validation failed: {Error}", errorMessage); + } + }); + + // Wire up startup form required notification (from hub) + _hub.On("StartupFormRequired", (title) => + { + logger.LogInformation("Startup form required: {Title}", title); + _isStartupFormRequired = true; + }); + + await _hub.StartAsync(stoppingToken); logger.LogInformation("Connected to Aspire Project Commands Hub: Registering identity..."); // Grab my suffix from OTEL env vars so the AppHost signalr hub can correctly isolate this client (i.e. there may be replicas) - var aspireResourceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME")!; + var aspireServiceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME")!; var aspireResourceSuffix = Environment.GetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES")!.Split("=")[1]; - - await hub.InvokeAsync("Identify", $"{aspireResourceName}-{aspireResourceSuffix}", stoppingToken); + _aspireResourceName = $"{aspireServiceName}-{aspireResourceSuffix}"; + + await _hub.InvokeAsync("Identify", _aspireResourceName, stoppingToken); // block until shutdown / stop await Task.Delay(Timeout.Infinite, stoppingToken); @@ -68,10 +143,61 @@ await Task.Run(async () => }, stoppingToken); } + /// public event Func CommandReceived { add => _commandHandlers.Add(value); remove => _commandHandlers.Remove(value); } + + /// + public event Func, IServiceProvider, Task>? StartupFormReceived + { + add + { + if (value != null) + _startupFormHandlers.Add(value); + } + remove + { + if (value != null) + _startupFormHandlers.Remove(value); + } + } + + /// + public async Task?> 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; + } } -} \ No newline at end of file +} diff --git a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs index 80878c4..0fff607 100644 --- a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs +++ b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs @@ -9,6 +9,30 @@ public interface IAspireProjectCommanderClient /// Occurs when a command is received. The name of the command is passed as an argument. /// public event Func CommandReceived; + + /// + /// Occurs when startup form data is received from the AppHost. + /// The handler should return true if validation succeeds, false otherwise. + /// + public event Func, IServiceProvider, Task>? StartupFormReceived; + + /// + /// Gets whether a startup form is required for this project. + /// + bool IsStartupFormRequired { get; } + + /// + /// Gets whether the startup form has been completed. + /// + bool IsStartupFormCompleted { get; } + + /// + /// Waits for the startup form to be completed by the user. + /// Returns the form data once submitted, or null if no startup form is configured. + /// + /// Cancellation token. + /// The form data dictionary, or null if no startup form is required. + Task?> WaitForStartupFormAsync(CancellationToken cancellationToken = default); } /// diff --git a/copilot.md b/copilot.md new file mode 100644 index 0000000..21d5625 --- /dev/null +++ b/copilot.md @@ -0,0 +1,41 @@ +# Copilot guidance for AspireProjectCommander + +## Project summary +This repo contains .NET Aspire libraries that enable custom project commands from the Aspire dashboard. There are two NuGet packages plus a sample Aspire app host and sample projects that demonstrate usage. + +## Key projects and locations +- Libraries (NuGet): + - Hosting package: Src/Nivot.Aspire.Hosting.ProjectCommander + - Integration package: Src/Nivot.Aspire.ProjectCommander +- Sample Aspire app: + - AppHost: Sample/ProjectCommander.AppHost + - Service defaults: Sample/ProjectCommander.ServiceDefaults + - Sample projects: Sample/DataGenerator, Sample/Consumer, Sample/SpiraLog +- Tests: ProjectCommander.Tests + +## Target framework and SDK +- Uses .NET SDK 9.0.100 (global.json). +- Solutions: ProjectCommander.sln (primary), Packages.sln (packaging focus). + +## Build, test, and run +- Build solution: dotnet build ProjectCommander.sln +- Run tests: dotnet test ProjectCommander.Tests/ProjectCommander.Tests.csproj +- Run sample AppHost with the Aspire CLI: aspire run + +## Conventions and tips for agents +- Prefer editing library code under Src/* for product changes; Sample/* is for demos. +- Avoid reformatting unrelated code; keep existing patterns and public APIs stable. +- When adding new public surface area, update README.md if it impacts usage examples. +- Packaging metadata is centralized in Src/Directory.Build.props. + +## Common entry points +- Hosting extensions and resource wiring live in: + - Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs + - Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +- Integration client and DI entry points live in: + - Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs + - Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs + +## Tests +- Keep new tests alongside ProjectCommander.Tests. +- Favor integration-style tests when validating end-to-end command flow. diff --git a/schemas/projectcommander-v1.schema.json b/schemas/projectcommander-v1.schema.json new file mode 100644 index 0000000..c6f03d9 --- /dev/null +++ b/schemas/projectcommander-v1.schema.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/oising/AspireProjectCommander/main/schemas/projectcommander-v1.schema.json", + "title": "Project Commander Manifest", + "description": "Schema for projectcommander.json manifest files that define commands and startup forms for Aspire Project Commander.", + "type": "object", + "required": ["version"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to this JSON schema for validation and IntelliSense." + }, + "version": { + "type": "string", + "enum": ["1.0"], + "description": "The version of the manifest schema. Currently only '1.0' is supported." + }, + "startupForm": { + "$ref": "#/$defs/startupForm", + "description": "Optional startup form that must be completed before the project starts its main work." + }, + "commands": { + "type": "array", + "description": "Commands that should be added to the Aspire dashboard for this project.", + "items": { + "$ref": "#/$defs/command" + } + } + }, + "$defs": { + "inputType": { + "type": "string", + "enum": ["Text", "SecretText", "Choice", "Boolean", "Number"], + "description": "The type of input control to display." + }, + "input": { + "type": "object", + "description": "Defines an input field for a command or startup form.", + "required": ["name", "inputType"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The unique name of the input field. Used as the key in the returned data dictionary." + }, + "label": { + "type": "string", + "description": "The label displayed to the user. Defaults to the name if not specified." + }, + "description": { + "type": "string", + "description": "Optional description or help text for the input." + }, + "inputType": { + "$ref": "#/$defs/inputType" + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this input must be filled out." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text shown when the input is empty." + }, + "maxLength": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of characters allowed for text inputs." + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available options for Choice input type." + }, + "allowCustomChoice": { + "type": "boolean", + "default": false, + "description": "Whether custom values are allowed for Choice input type." + }, + "enableDescriptionMarkdown": { + "type": "boolean", + "default": false, + "description": "Whether the description supports Markdown rendering." + } + }, + "allOf": [ + { + "if": { + "properties": { + "inputType": { "const": "Choice" } + } + }, + "then": { + "required": ["options"], + "properties": { + "options": { + "minItems": 1 + } + } + } + } + ] + }, + "startupForm": { + "type": "object", + "description": "Defines a startup form that blocks project execution until filled out by the developer.", + "required": ["title", "inputs"], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "The title of the startup form dialog." + }, + "description": { + "type": "string", + "description": "Optional description text displayed below the title." + }, + "inputs": { + "type": "array", + "description": "The input fields for the startup form.", + "items": { + "$ref": "#/$defs/input" + }, + "minItems": 1 + } + } + }, + "command": { + "type": "object", + "description": "Defines a command that appears in the Aspire dashboard for this project.", + "required": ["name", "displayName"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "The unique identifier for the command. Must be lowercase alphanumeric with hyphens." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "The display name shown in the Aspire dashboard." + }, + "description": { + "type": "string", + "description": "Optional description of what the command does." + }, + "iconName": { + "type": "string", + "description": "The icon name to display. Uses Fluent UI icon names (e.g., 'Play', 'Stop', 'Settings')." + }, + "iconVariant": { + "type": "string", + "enum": ["Regular", "Filled"], + "default": "Regular", + "description": "The icon variant to use." + }, + "confirmationMessage": { + "type": "string", + "description": "Optional confirmation message to show before executing the command." + }, + "isHighlighted": { + "type": "boolean", + "default": false, + "description": "Whether the command should be visually highlighted in the dashboard." + }, + "inputs": { + "type": "array", + "description": "Optional input fields to prompt for when the command is executed.", + "items": { + "$ref": "#/$defs/input" + } + } + } + } + }, + "examples": [ + { + "version": "1.0", + "startupForm": { + "title": "Configure Service", + "description": "Please configure the service settings.", + "inputs": [ + { + "name": "connectionString", + "label": "Connection String", + "inputType": "SecretText", + "required": true + } + ] + }, + "commands": [ + { + "name": "start", + "displayName": "Start Processing", + "iconName": "Play" + }, + { + "name": "stop", + "displayName": "Stop Processing", + "iconName": "Stop" + } + ] + } + ] +} From e4852d0f28a7546b5c53976b5597c2faa078b783 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 5 Feb 2026 16:32:56 -0500 Subject: [PATCH 04/16] disable config for startup form after submitted; require resource restart to config again --- Src/Directory.Build.props | 4 ++-- ...sourceBuilderProjectCommanderExtensions.cs | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Src/Directory.Build.props b/Src/Directory.Build.props index ef26dbb..d7ae579 100644 --- a/Src/Directory.Build.props +++ b/Src/Directory.Build.props @@ -9,13 +9,13 @@ https://github.com/oising/AspireProjectCommander/ icon.png README.md - Aspire Project Commander is a set of packages that lets you send simple string commands from the dashboard directly to projects. + Aspire Project Commander is a set of packages that lets you send commands or ask for initialization data from the dashboard directly to and from projects. aspire hosting integration apphost signal True true True true - 1.1.0 + 2.0.0 oisin diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 9026447..579b1f5 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -74,6 +74,7 @@ public static IResourceBuilder WithProjectManifest(this IResourceBuilder /// Registers the startup form command for a resource with a startup form. + /// The command is disabled after successful submission and requires a resource restart to re-enable. /// private static void RegisterStartupFormCommand(IResourceBuilder builder, StartupFormAnnotation annotation) where T : ProjectResource @@ -86,6 +87,16 @@ private static void RegisterStartupFormCommand(IResourceBuilder builder, S displayName: form.Title, executeCommand: async (context) => { + // Check if the startup form has already been completed + if (annotation.IsCompleted) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = "Startup form has already been submitted. Restart the resource to configure again." + }; + } + try { var model = context.ServiceProvider.GetRequiredService(); @@ -132,7 +143,7 @@ await hubResource.Hub.Clients.Group(groupName).SendAsync( formData, context.CancellationToken); - // Update annotation + // Update annotation to mark as completed annotation.FormData = formData; annotation.IsCompleted = true; @@ -151,7 +162,11 @@ await hubResource.Hub.Clients.Group(groupName).SendAsync( { IconName = "Settings", IconVariant = IconVariant.Regular, - IsHighlighted = true + IsHighlighted = true, + // Dynamically update command state based on whether form is completed + UpdateState = (context) => annotation.IsCompleted + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled }); } From 275e6a1a4a0a943c10272c9b0537a2ec8cdb87ef Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:11:16 -0500 Subject: [PATCH 05/16] Fix: Return Success=false for cancelled operations (#9) * Initial plan * Fix: Return Success=false for cancelled operations Co-authored-by: oising <1844001+oising@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oising <1844001+oising@users.noreply.github.com> --- .../ResourceBuilderProjectCommanderExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 579b1f5..cca7c12 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -124,7 +124,7 @@ private static void RegisterStartupFormCommand(IResourceBuilder builder, S { return new ExecuteCommandResult { - Success = true, + Success = false, ErrorMessage = "Configuration cancelled." }; } @@ -243,7 +243,7 @@ public static IResourceBuilder WithProjectCommands( if (result.Canceled) { - return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." }; + return new ExecuteCommandResult() { Success = false, ErrorMessage = "User cancelled command." }; } var args = new string?[command.Arguments.Length]; From c64f6881bffeedda68c3c97d34fcb6a86ebef7f0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:43:56 -0500 Subject: [PATCH 06/16] Add unit tests and extract startup form logic into testable services (#10) * Initial plan * Add xUnit 2 test project with startup form tests and extract logic into services Co-authored-by: oising <1844001+oising@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oising <1844001+oising@users.noreply.github.com> --- .../ProjectCommander.UnitTests.csproj | 37 +++++ .../ResourceNameParserTests.cs | 38 +++++ .../StartupFormServiceTests.cs | 140 ++++++++++++++++++ ProjectCommander.sln | 6 + .../IResourceNameParser.cs | 15 ++ ...vot.Aspire.Hosting.ProjectCommander.csproj | 4 + .../ProjectCommanderHub.cs | 11 +- .../ProjectCommanderHubResource.cs | 3 + .../ResourceNameParser.cs | 21 +++ .../AspireProjectCommanderClientWorker.cs | 55 ++----- .../IStartupFormService.cs | 39 +++++ .../Nivot.Aspire.ProjectCommander.csproj | 4 + ...lectionAspireProjectCommanderExtensions.cs | 1 + .../StartupFormService.cs | 80 ++++++++++ 14 files changed, 409 insertions(+), 45 deletions(-) create mode 100644 ProjectCommander.UnitTests/ProjectCommander.UnitTests.csproj create mode 100644 ProjectCommander.UnitTests/ResourceNameParserTests.cs create mode 100644 ProjectCommander.UnitTests/StartupFormServiceTests.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/IResourceNameParser.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs create mode 100644 Src/Nivot.Aspire.ProjectCommander/IStartupFormService.cs create mode 100644 Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs diff --git a/ProjectCommander.UnitTests/ProjectCommander.UnitTests.csproj b/ProjectCommander.UnitTests/ProjectCommander.UnitTests.csproj new file mode 100644 index 0000000..5e319b6 --- /dev/null +++ b/ProjectCommander.UnitTests/ProjectCommander.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/ProjectCommander.UnitTests/ResourceNameParserTests.cs b/ProjectCommander.UnitTests/ResourceNameParserTests.cs new file mode 100644 index 0000000..48aedeb --- /dev/null +++ b/ProjectCommander.UnitTests/ResourceNameParserTests.cs @@ -0,0 +1,38 @@ +using CommunityToolkit.Aspire.Hosting.ProjectCommander; + +namespace ProjectCommander.UnitTests; + +public class ResourceNameParserTests +{ + private readonly ResourceNameParser _parser; + + public ResourceNameParserTests() + { + _parser = new ResourceNameParser(); + } + + [Theory] + [InlineData("datagenerator-abc123", "datagenerator")] + [InlineData("consumer-xyz789", "consumer")] + [InlineData("my-service-12345", "my")] + [InlineData("singlename", "singlename")] + [InlineData("resource-with-multiple-hyphens-123", "resource")] + public void GetBaseResourceName_ParsesCorrectly(string input, string expected) + { + // Act + var result = _parser.GetBaseResourceName(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void GetBaseResourceName_ThrowsForInvalidInput(string? input) + { + // Act & Assert + Assert.Throws(() => _parser.GetBaseResourceName(input!)); + } +} diff --git a/ProjectCommander.UnitTests/StartupFormServiceTests.cs b/ProjectCommander.UnitTests/StartupFormServiceTests.cs new file mode 100644 index 0000000..6d785b3 --- /dev/null +++ b/ProjectCommander.UnitTests/StartupFormServiceTests.cs @@ -0,0 +1,140 @@ +using CommunityToolkit.Aspire.ProjectCommander; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ProjectCommander.UnitTests; + +public class StartupFormServiceTests +{ + private readonly Mock> _mockLogger; + private readonly StartupFormService _service; + + public StartupFormServiceTests() + { + _mockLogger = new Mock>(); + _service = new StartupFormService(_mockLogger.Object); + } + + [Fact] + public void IsStartupFormRequired_DefaultsToFalse() + { + // Assert + Assert.False(_service.IsStartupFormRequired); + } + + [Fact] + public void SetStartupFormRequired_SetsPropertyCorrectly() + { + // Act + _service.SetStartupFormRequired(true); + + // Assert + Assert.True(_service.IsStartupFormRequired); + } + + [Fact] + public void IsStartupFormCompleted_DefaultsToFalse() + { + // Assert + Assert.False(_service.IsStartupFormCompleted); + } + + [Fact] + public void CompleteStartupForm_SetsPropertiesCorrectly() + { + // Arrange + var formData = new Dictionary + { + { "field1", "value1" }, + { "field2", "value2" } + }; + + // Act + _service.CompleteStartupForm(formData); + + // Assert + Assert.True(_service.IsStartupFormCompleted); + Assert.NotNull(_service.StartupFormData); + Assert.Equal(2, _service.StartupFormData.Count); + Assert.Equal("value1", _service.StartupFormData["field1"]); + } + + [Fact] + public void CompleteStartupForm_ThrowsWhenFormDataIsNull() + { + // Act & Assert + Assert.Throws(() => _service.CompleteStartupForm(null!)); + } + + [Fact] + public async Task WaitForStartupFormAsync_ReturnsNullWhenNotRequired() + { + // Arrange + _service.SetStartupFormRequired(false); + + // Act + var result = await _service.WaitForStartupFormAsync(); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task WaitForStartupFormAsync_ReturnsDataWhenAlreadyCompleted() + { + // Arrange + var formData = new Dictionary + { + { "field1", "value1" } + }; + _service.SetStartupFormRequired(true); + _service.CompleteStartupForm(formData); + + // Act + var result = await _service.WaitForStartupFormAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(formData, result); + } + + [Fact] + public async Task WaitForStartupFormAsync_BlocksUntilFormCompleted() + { + // Arrange + _service.SetStartupFormRequired(true); + var formData = new Dictionary + { + { "field1", "value1" } + }; + + // Act + var waitTask = _service.WaitForStartupFormAsync(); + + // Verify the task is not completed yet + Assert.False(waitTask.IsCompleted); + + // Complete the form + _service.CompleteStartupForm(formData); + + // Wait a bit for the task to complete + var result = await waitTask; + + // Assert + Assert.NotNull(result); + Assert.Equal(formData, result); + } + + [Fact] + public async Task WaitForStartupFormAsync_ThrowsWhenCancelled() + { + // Arrange + _service.SetStartupFormRequired(true); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.WaitForStartupFormAsync(cts.Token)); + } +} diff --git a/ProjectCommander.sln b/ProjectCommander.sln index 6321566..64a6756 100644 --- a/ProjectCommander.sln +++ b/ProjectCommander.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectCommander.Tests", "P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpiraLog", "Sample\SpiraLog\SpiraLog.csproj", "{D45D1DF8-C846-1C71-D6DD-AC07B173733A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectCommander.UnitTests", "ProjectCommander.UnitTests\ProjectCommander.UnitTests.csproj", "{CCFE1630-B697-45E9-9124-6B18AD3FAC4A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Debug|Any CPU.Build.0 = Debug|Any CPU {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Release|Any CPU.ActiveCfg = Release|Any CPU {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Release|Any CPU.Build.0 = Release|Any CPU + {CCFE1630-B697-45E9-9124-6B18AD3FAC4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCFE1630-B697-45E9-9124-6B18AD3FAC4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCFE1630-B697-45E9-9124-6B18AD3FAC4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCFE1630-B697-45E9-9124-6B18AD3FAC4A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/IResourceNameParser.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/IResourceNameParser.cs new file mode 100644 index 0000000..f843e4d --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/IResourceNameParser.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Service for parsing Aspire resource names. +/// +public interface IResourceNameParser +{ + /// + /// Extracts the base resource name from a full resource name that may include a suffix. + /// Example: "datagenerator-abc123" -> "datagenerator" + /// + /// The full resource name. + /// The base resource name without suffix. + string GetBaseResourceName(string resourceName); +} diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj index 8414adb..f4f503c 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj @@ -6,6 +6,10 @@ Aspire Project Commander Hosting icon.png + + + + diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 1a6e4b7..88512c0 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -12,7 +12,12 @@ namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; /// /// /// -internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService loggerService, DistributedApplicationModel model) : Hub +/// +internal sealed class ProjectCommanderHub( + ILogger logger, + ResourceLoggerService loggerService, + DistributedApplicationModel model, + IResourceNameParser resourceNameParser) : Hub { /// /// Identifies the connecting client by adding it to a group named after the resource. @@ -28,7 +33,7 @@ public async Task Identify([ResourceName] string resourceName) //, ProjectComman await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); // Check if this resource has a startup form and notify the client - var baseResourceName = resourceName.Split('-')[0]; + var baseResourceName = resourceNameParser.GetBaseResourceName(resourceName); var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); if (resource != null) @@ -55,7 +60,7 @@ public async Task StartupFormCompleted([ResourceName] string resourceName, bool logger.LogInformation("{ResourceName} startup form completed: Success={Success}", resourceName, success); // Find the resource and update the annotation - var baseResourceName = resourceName.Split('-')[0]; + var baseResourceName = resourceNameParser.GetBaseResourceName(resourceName); var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); if (resource != null) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs index fedfcde..33c4e2c 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs @@ -50,6 +50,9 @@ private IHubContext BuildHub(ResourceLoggerService loggerSe // proxy logging to AppHost logger host.Services.AddSingleton(_logger); + // Add resource name parser service + host.Services.AddSingleton(); + host.WebHost.UseUrls($"{(options.UseHttps ? "https" : "http")}://localhost:{options.HubPort}"); host.Services.AddSignalR() diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs new file mode 100644 index 0000000..efd8376 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs @@ -0,0 +1,21 @@ +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Default implementation of resource name parser. +/// +internal sealed class ResourceNameParser : IResourceNameParser +{ + /// + public string GetBaseResourceName(string resourceName) + { + if (string.IsNullOrWhiteSpace(resourceName)) + { + throw new ArgumentException("Resource name cannot be null or empty.", nameof(resourceName)); + } + + // Split on first hyphen to extract base name + // Example: "datagenerator-abc123" -> "datagenerator" + var parts = resourceName.Split('-', 2); + return parts[0]; + } +} diff --git a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs index c804506..ae5551c 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -10,32 +10,34 @@ namespace CommunityToolkit.Aspire.ProjectCommander /// /// /// + /// /// - internal sealed class AspireProjectCommanderClientWorker(IConfiguration configuration, IServiceProvider serviceProvider, ILogger logger) + internal sealed class AspireProjectCommanderClientWorker( + IConfiguration configuration, + IServiceProvider serviceProvider, + IStartupFormService startupFormService, + ILogger logger) : BackgroundService, IAspireProjectCommanderClient { private readonly List> _commandHandlers = new(); private readonly List, IServiceProvider, Task>> _startupFormHandlers = new(); - private readonly TaskCompletionSource> _startupFormCompletionSource = new(); private HubConnection? _hub; private string? _aspireResourceName; - private bool _isStartupFormRequired; - private bool _isStartupFormCompleted; - private Dictionary? _startupFormData; /// - public bool IsStartupFormRequired => _isStartupFormRequired; + public bool IsStartupFormRequired => startupFormService.IsStartupFormRequired; /// - public bool IsStartupFormCompleted => _isStartupFormCompleted; + public bool IsStartupFormCompleted => startupFormService.IsStartupFormCompleted; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Run(async () => { // Check if startup form is required via environment variable - _isStartupFormRequired = Environment.GetEnvironmentVariable("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED") == "true"; + var isRequired = Environment.GetEnvironmentVariable("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED") == "true"; + startupFormService.SetStartupFormRequired(isRequired); var connectionString = configuration.GetConnectionString("project-commander"); @@ -108,9 +110,7 @@ await Task.Run(async () => if (success) { - _isStartupFormCompleted = true; - _startupFormData = formData; - _startupFormCompletionSource.TrySetResult(formData); + startupFormService.CompleteStartupForm(formData); logger.LogInformation("Startup form completed successfully"); } else @@ -123,7 +123,7 @@ await Task.Run(async () => _hub.On("StartupFormRequired", (title) => { logger.LogInformation("Startup form required: {Title}", title); - _isStartupFormRequired = true; + startupFormService.SetStartupFormRequired(true); }); await _hub.StartAsync(stoppingToken); @@ -168,36 +168,7 @@ public event Func CommandReceived /// public async Task?> 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; + return await startupFormService.WaitForStartupFormAsync(cancellationToken); } } } diff --git a/Src/Nivot.Aspire.ProjectCommander/IStartupFormService.cs b/Src/Nivot.Aspire.ProjectCommander/IStartupFormService.cs new file mode 100644 index 0000000..3189f39 --- /dev/null +++ b/Src/Nivot.Aspire.ProjectCommander/IStartupFormService.cs @@ -0,0 +1,39 @@ +namespace CommunityToolkit.Aspire.ProjectCommander; + +/// +/// Service for managing startup form state and operations. +/// +public interface IStartupFormService +{ + /// + /// Gets whether a startup form is required for this project. + /// + bool IsStartupFormRequired { get; } + + /// + /// Gets whether the startup form has been completed. + /// + bool IsStartupFormCompleted { get; } + + /// + /// Gets the startup form data if completed. + /// + Dictionary? StartupFormData { get; } + + /// + /// Sets whether a startup form is required. + /// + void SetStartupFormRequired(bool required); + + /// + /// Completes the startup form with the provided data. + /// + void CompleteStartupForm(Dictionary formData); + + /// + /// Waits for the startup form to be completed. + /// + /// Cancellation token. + /// The form data dictionary, or null if no startup form is required. + Task?> WaitForStartupFormAsync(CancellationToken cancellationToken = default); +} diff --git a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj index 35404a7..3206533 100644 --- a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj @@ -5,6 +5,10 @@ enable Aspire Project Commander Integration + + + + True diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 8f6c755..893cf7d 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCo // services.AddSingleton(worker); // } + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs b/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs new file mode 100644 index 0000000..46b1cf7 --- /dev/null +++ b/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.ProjectCommander; + +/// +/// Default implementation of the startup form service. +/// +internal sealed class StartupFormService : IStartupFormService +{ + private readonly ILogger _logger; + private readonly TaskCompletionSource> _completionSource = new(); + + private bool _isStartupFormRequired; + private bool _isStartupFormCompleted; + private Dictionary? _startupFormData; + + public StartupFormService(ILogger logger) + { + _logger = logger; + } + + public bool IsStartupFormRequired => _isStartupFormRequired; + + public bool IsStartupFormCompleted => _isStartupFormCompleted; + + public Dictionary? StartupFormData => _startupFormData; + + public void SetStartupFormRequired(bool required) + { + _isStartupFormRequired = required; + _logger.LogDebug("Startup form required set to: {Required}", required); + } + + public void CompleteStartupForm(Dictionary formData) + { + if (formData == null) + { + throw new ArgumentNullException(nameof(formData)); + } + + _isStartupFormCompleted = true; + _startupFormData = formData; + _completionSource.TrySetResult(formData); + _logger.LogInformation("Startup form completed with {Count} fields", formData.Count); + } + + public async Task?> 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( + _completionSource.Task, + Task.Delay(Timeout.Infinite, cts.Token)); + + if (completedTask == _completionSource.Task) + { + return await _completionSource.Task; + } + + // Cancelled + cancellationToken.ThrowIfCancellationRequested(); + return null; + } +} From 34e24b2c68cd4885fa31b8a196d7ac9f947aac88 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 11 Feb 2026 15:39:32 -0500 Subject: [PATCH 07/16] fix some nits and tests --- .../ResourceNameParserTests.cs | 4 ++-- .../StartupFormServiceTests.cs | 2 +- .../ManifestReader.cs | 9 ++++++++- .../ResourceBuilderProjectCommanderExtensions.cs | 15 --------------- .../ResourceNameParser.cs | 9 +++++++-- .../IAspireProjectCommanderClient.cs | 6 +++--- global.json | 2 +- 7 files changed, 22 insertions(+), 25 deletions(-) diff --git a/ProjectCommander.UnitTests/ResourceNameParserTests.cs b/ProjectCommander.UnitTests/ResourceNameParserTests.cs index 48aedeb..3348107 100644 --- a/ProjectCommander.UnitTests/ResourceNameParserTests.cs +++ b/ProjectCommander.UnitTests/ResourceNameParserTests.cs @@ -14,9 +14,9 @@ public ResourceNameParserTests() [Theory] [InlineData("datagenerator-abc123", "datagenerator")] [InlineData("consumer-xyz789", "consumer")] - [InlineData("my-service-12345", "my")] + [InlineData("my-service-12345", "my-service")] [InlineData("singlename", "singlename")] - [InlineData("resource-with-multiple-hyphens-123", "resource")] + [InlineData("resource-with-multiple-hyphens-123", "resource-with-multiple-hyphens")] public void GetBaseResourceName_ParsesCorrectly(string input, string expected) { // Act diff --git a/ProjectCommander.UnitTests/StartupFormServiceTests.cs b/ProjectCommander.UnitTests/StartupFormServiceTests.cs index 6d785b3..a48b609 100644 --- a/ProjectCommander.UnitTests/StartupFormServiceTests.cs +++ b/ProjectCommander.UnitTests/StartupFormServiceTests.cs @@ -131,7 +131,7 @@ public async Task WaitForStartupFormAsync_ThrowsWhenCancelled() // Arrange _service.SetStartupFormRequired(true); var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Assert.ThrowsAsync( diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs index 6a56e59..3efe2fe 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs @@ -37,7 +37,14 @@ internal static class ManifestReader } var json = File.ReadAllText(manifestPath); - return JsonSerializer.Deserialize(json, JsonOptions); + var manifest = JsonSerializer.Deserialize(json, JsonOptions); + + if (manifest is null) + { + throw new JsonException($"Failed to deserialize project command manifest from '{manifestPath}'."); + } + + return manifest; } /// diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index cca7c12..3a06bf3 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -212,21 +212,6 @@ public static IResourceBuilder WithProjectCommands( { builder.WithCommand(command.Name, command.DisplayName, async (context) => { - // Thank you LLMs for the idea, but - // // DO NOT DO THIS!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // // it might work in simple cases, but it will fail in complex ones - // // trust me, I've tried - // // and this is what LLMs are good for, telling you what NOT to do - // // because you are all wondering what the heck I'm talking about - // // lets run this and see what happens - // // because I know you are all thinking it - // // and I don't want to hear it - // i++; - // builder.WithCommand($"dynamic-command-{i}", command.DisplayName, async (context) => - // { - // return new ExecuteCommandResult() { Success = true }; - // }, - // new CommandOptions { }); bool success = false; string errorMessage = string.Empty; diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs index efd8376..2e9124f 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs @@ -15,7 +15,12 @@ public string GetBaseResourceName(string resourceName) // Split on first hyphen to extract base name // Example: "datagenerator-abc123" -> "datagenerator" - var parts = resourceName.Split('-', 2); - return parts[0]; + if (!resourceName.Contains('-')) + { + return resourceName; + } + + var baseName = resourceName[..resourceName.LastIndexOf("-", StringComparison.Ordinal)]; + return baseName; } } diff --git a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs index 0fff607..7c425a9 100644 --- a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs +++ b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs @@ -43,9 +43,9 @@ public static class AspireProjectCommanderClientExtensions /// /// Registers a command with the specified name. /// - /// - /// - /// + /// The project commander client to register the command with. + /// The unique name that identifies the command to be registered. + /// The same instance, to allow fluent configuration. public static IAspireProjectCommanderClient RegisterProjectCommand(this IAspireProjectCommanderClient client, string commandName) { diff --git a/global.json b/global.json index 3af711c..2256cf8 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "rollForward": "feature", - "version": "9.0.100" + "version": "10.0.100" } } \ No newline at end of file From 9dfec683f70d91ac3fd6a0edaaf1432fa18dfbd5 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 11 Feb 2026 16:42:11 -0500 Subject: [PATCH 08/16] bump aspire to 13.1.1 --- Sample/Consumer/Consumer.csproj | 2 +- Sample/DataGenerator/DataGenerator.csproj | 2 +- .../ProjectCommander.AppHost/ProjectCommander.AppHost.csproj | 4 ++-- .../Nivot.Aspire.Hosting.ProjectCommander.csproj | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sample/Consumer/Consumer.csproj b/Sample/Consumer/Consumer.csproj index 7f4d8d7..a8d663d 100644 --- a/Sample/Consumer/Consumer.csproj +++ b/Sample/Consumer/Consumer.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/DataGenerator/DataGenerator.csproj b/Sample/DataGenerator/DataGenerator.csproj index 7f4d8d7..a8d663d 100644 --- a/Sample/DataGenerator/DataGenerator.csproj +++ b/Sample/DataGenerator/DataGenerator.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj index d135980..d3181dc 100644 --- a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj +++ b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj index f4f503c..2944be0 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers From b4361b1485168bad0da92c1cf832886ed491dd65 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 14:13:20 -0500 Subject: [PATCH 09/16] prevent repeat registration --- ...lectionAspireProjectCommanderExtensions.cs | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 893cf7d..3a58e72 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -9,6 +9,8 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class ServiceCollectionAspireProjectCommanderExtensions { + private static bool _isRegistered; + /// /// Adds the Aspire Project Commander client to the service collection. /// @@ -16,19 +18,15 @@ public static class ServiceCollectionAspireProjectCommanderExtensions /// Returns the updated service collection. public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services) { - // var sp = services.BuildServiceProvider(); - - // if (sp.GetService() is null) - // { - // var worker = ActivatorUtilities.CreateInstance(sp); - // services.AddSingleton(worker); - // services.AddSingleton(worker); - // } - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); + // No way to use the TryAdd* variants as they don't cover this scenario/overloads + if (!_isRegistered) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + _isRegistered = true; + } return services; } From be7f0fc0605f3749960e19af7e78464c2852be94 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 15:34:11 -0500 Subject: [PATCH 10/16] switch to a depedant resource for startup form --- CHANGELOG.md | 61 ++++++-- README.md | 21 ++- Sample/ProjectCommander.AppHost/Program.cs | 10 +- Sample/sequenceDiagram.mmd | 50 +++--- ...DistributedApplicationBuilderExtensions.cs | 123 +++++++++++++++ .../ProjectCommanderHub.cs | 31 ++-- ...sourceBuilderProjectCommanderExtensions.cs | 145 ++++-------------- .../StartupFormAnnotation.cs | 40 ----- .../StartupFormResource.cs | 59 +++++++ .../StartupFormResourceAnnotation.cs | 24 +++ .../AspireProjectCommanderClientWorker.cs | 13 +- copilot.md | 2 +- 12 files changed, 375 insertions(+), 204 deletions(-) delete mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResource.cs create mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResourceAnnotation.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c100..e54b2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,27 +15,50 @@ Projects can now define their own commands using a `projectcommander.json` manif **New extension method:** - `WithProjectManifest()` - Reads commands and startup forms from the project's `projectcommander.json` file + - **Breaking change:** Now returns `(IResourceBuilder, IResourceBuilder?)` tuple **Manifest features:** - Define commands with name, display name, description, and icon - Specify interactive inputs for commands (Text, SecretText, Choice, Boolean, Number) - Define startup forms that must be completed before the project starts -#### Startup Forms +#### Startup Form Resource -Projects can now require configuration before starting their main work via interactive startup forms. +Startup forms are now represented as first-class Aspire resources. This enables using Aspire's native `WaitFor` semantics to block projects until configuration is complete. -**New client interface members:** -- `WaitForStartupFormAsync(CancellationToken)` - Blocks until the startup form is completed by the user -- `IsStartupFormRequired` - Indicates if a startup form is configured for this project -- `IsStartupFormCompleted` - Indicates if the startup form has been submitted -- `StartupFormReceived` event - Fires when startup form data is received +**New types:** +- `StartupFormResource` - Custom Aspire resource representing a startup form +- `StartupFormResourceAnnotation` - Links a project to its startup form resource + +**New extension method:** +- `WithStartupFormBehavior()` - Configures the startup form resource with the "Configure" command **How it works:** 1. Define a `startupForm` section in your `projectcommander.json` -2. Call `await commander.WaitForStartupFormAsync()` in your project's startup -3. The project waits for the user to click the Configure command in the dashboard -4. Once submitted, the form data is returned and the project continues +2. Call `WithProjectManifest()` which returns a tuple with the optional `StartupFormResource` +3. Call `WithStartupFormBehavior()` on the form resource to register the Configure command +4. Use `WaitFor(startupFormResource)` to block the project until the form is completed +5. The form resource appears in the dashboard with state `WaitingForConfiguration` +6. When the user submits the form, the resource transitions to `Running` and the project starts + +**Example:** +```csharp +var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") + .WithReference(commander) + .WaitFor(commander) + .WithProjectManifest(); + +if (datageneratorConfig is not null) +{ + datageneratorConfig.WithStartupFormBehavior(); + datagenerator.WaitFor(datageneratorConfig); +} +``` + +**Client-side:** +- `WaitForStartupFormAsync()` still works but returns immediately with cached data since Aspire handles blocking +- `IsStartupFormRequired` / `IsStartupFormCompleted` - Query form state +- `StartupFormReceived` event - Fires when form data is received #### Combining Manifest and Code Commands @@ -47,16 +70,24 @@ You can now use both `WithProjectManifest()` and `WithProjectCommands()` togethe |------|---------| | `ProjectCommandManifest.cs` | Manifest types for deserializing `projectcommander.json` | | `ManifestReader.cs` | JSON parser and InputDefinition to InteractionInput converter | -| `StartupFormAnnotation.cs` | Resource annotation for tracking startup form state | +| `StartupFormResource.cs` | Custom Aspire resource for startup forms | +| `StartupFormResourceAnnotation.cs` | Links project to its startup form resource | + +### Removed Files + +| File | Reason | +|------|--------| +| `StartupFormAnnotation.cs` | Replaced by `StartupFormResource` and `StartupFormResourceAnnotation` | ### Modified Files | File | Changes | |------|---------| -| `ResourceBuilderProjectCommanderExtensions.cs` | Added `WithProjectManifest()` and startup form command registration | -| `ProjectCommanderHub.cs` | Added startup form lifecycle methods | -| `IAspireProjectCommanderClient.cs` | Added startup form interface members | -| `AspireProjectCommanderClientWorker.cs` | Implemented startup form handling | +| `ResourceBuilderProjectCommanderExtensions.cs` | `WithProjectManifest()` now returns tuple with `StartupFormResource` | +| `DistributedApplicationBuilderExtensions.cs` | Added `WithStartupFormBehavior()` extension | +| `ProjectCommanderHub.cs` | Uses `StartupFormResourceAnnotation`, sends cached form data on connect | +| `IAspireProjectCommanderClient.cs` | Startup form interface members | +| `AspireProjectCommanderClientWorker.cs` | Handles new `ReceiveStartupForm` message format | ### Example Manifest diff --git a/README.md b/README.md index ebb17bf..3752521 100644 --- a/README.md +++ b/README.md @@ -102,16 +102,31 @@ var builder = DistributedApplication.CreateBuilder(args); var commander = builder.AddAspireProjectCommander(); -builder.AddProject("datagenerator") +// WithProjectManifest returns a tuple: (project builder, optional startup form resource) +var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") .WithReference(commander) .WaitFor(commander) - .WithProjectManifest(); // Reads commands from projectcommander.json + .WithProjectManifest(); // Reads commands and startup form from projectcommander.json + +// If the project has a startup form, configure it and make the project wait for it +if (datageneratorConfig is not null) +{ + datageneratorConfig.WithStartupFormBehavior(); + datagenerator.WaitFor(datageneratorConfig); // Project won't start until form is completed +} builder.Build().Run(); ``` +The startup form appears as a separate resource in the Aspire dashboard with state `WaitingForConfiguration`. +The project is blocked by Aspire's `WaitFor` until the developer clicks "Configure" and submits the form, +at which point the form resource transitions to `Running` and the project starts. + ### Handling Startup Forms in Projects +When using the `WaitFor(startupFormResource)` pattern, Aspire blocks the project from starting until the form is completed. +Once the project starts, it can retrieve the form data using `WaitForStartupFormAsync()` which returns immediately with the cached values: + ```csharp public sealed class DataGeneratorWorker( IAspireProjectCommanderClient commander, @@ -119,7 +134,7 @@ public sealed class DataGeneratorWorker( { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // Wait for startup form to be completed (blocks until user submits) + // Get startup form data (returns immediately since form was already completed before project started) var config = await commander.WaitForStartupFormAsync(stoppingToken); if (config != null) diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 78029a2..b860894 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -16,13 +16,21 @@ var client = datahub.AddConsumerGroup("client"); -builder.AddProject("datagenerator") +// WithProjectManifest now returns a tuple with the project and optional startup form resource +var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") .WithReference(datahub) .WithReference(commander) .WaitFor(commander) .WaitFor(datahub) .WithProjectManifest(); // Reads commands and startup form from projectcommander.json +// If the project has a startup form, configure it and make the project wait for it +if (datageneratorConfig is not null) +{ + datageneratorConfig.WithStartupFormBehavior(); + datagenerator.WaitFor(datageneratorConfig); +} + builder.AddProject("consumer") .WithReference(commander) .WaitFor(commander) diff --git a/Sample/sequenceDiagram.mmd b/Sample/sequenceDiagram.mmd index 8b1ca34..ba45140 100644 --- a/Sample/sequenceDiagram.mmd +++ b/Sample/sequenceDiagram.mmd @@ -11,17 +11,37 @@ sequenceDiagram AppHost->>AppHost: Read projectcommander.json
from project directory alt Manifest has StartupForm - AppHost->>AppHost: Create StartupFormAnnotation + AppHost->>AppHost: Create StartupFormResource
(e.g., "datagenerator-config") + AppHost->>AppHost: Attach StartupFormResourceAnnotation
to parent project AppHost->>AppHost: Set PROJECTCOMMANDER_STARTUP_FORM_REQUIRED=true - AppHost->>AppHost: Register "Configure" command + AppHost->>AppHost: Set resource state = "WaitingForConfiguration" end AppHost->>AppHost: Register commands from manifest
via WithProjectCommands() + AppHost->>AppHost: WithStartupFormBehavior() called + AppHost->>AppHost: Register "Configure" command
on StartupFormResource + AppHost->>Hub: Start SignalR Hub on localhost:27960 Hub-->>AppHost: Hub started successfully - Note over AppHost,Worker: Project Connection + Note over AppHost,Worker: Aspire WaitFor Semantics + + AppHost->>AppHost: datagenerator.WaitFor(datageneratorConfig) + Note over AppHost: Project blocked until
StartupFormResource is Running + + Dashboard->>AppHost: User clicks "Configure" on
datagenerator-config resource + + AppHost->>Dashboard: IInteractionService.PromptInputsAsync()
Show form with inputs + Dashboard-->>AppHost: User submits form data + + AppHost->>AppHost: StartupFormResource.MarkCompleted(formData) + AppHost->>AppHost: PublishUpdateAsync(state = Running) + Note over AppHost: WaitFor unblocked -
project can now start + + AppHost->>Hub: hub.Clients.All.SendAsync
("ReceiveStartupForm", resourceName, formData) + + Note over AppHost,Worker: Project Connection (after WaitFor unblocks) DataGen->>DataGen: Check PROJECTCOMMANDER_STARTUP_FORM_REQUIRED env var DataGen->>Hub: Connect to SignalR Hub @@ -31,35 +51,21 @@ sequenceDiagram Note over DataGen: resourceName = "datagenerator-{suffix}" Hub->>Hub: Groups.AddToGroupAsync(connectionId, resourceName) - alt Resource has StartupFormAnnotation - Hub->>DataGen: SendAsync("StartupFormRequired", title) - Note over DataGen: Client knows startup form is pending + alt StartupFormResource.IsCompleted + Hub->>DataGen: SendAsync("ReceiveStartupForm",
resourceName, cachedFormData) + Note over DataGen: Hub sends cached form data
to newly connected client end Hub-->>DataGen: Connection established and grouped DataGen->>DataGen: Register "ReceiveCommand" handler DataGen->>DataGen: Register "ReceiveStartupForm" handler - Note over DataGen: hub.On("ReceiveCommand", handler)
hub.On("ReceiveStartupForm", handler) + Note over DataGen: hub.On("ReceiveStartupForm", handler) - Note over AppHost,Worker: Startup Form Flow (if configured) - - Worker->>DataGen: WaitForStartupFormAsync(cancellationToken) - Note over Worker: Worker blocks until
startup form is completed - - Dashboard->>AppHost: User clicks "Configure Data Generator"
command in dashboard - - AppHost->>Dashboard: IInteractionService.PromptInputsAsync()
Show form with inputs - Dashboard-->>AppHost: User submits form data - - AppHost->>Hub: hub.Clients.Group(resourceName)
.SendAsync("ReceiveStartupForm", formData) - - Hub->>DataGen: ReceiveStartupForm(formData) + DataGen->>DataGen: ReceiveStartupForm(resourceName, formData) DataGen->>DataGen: Invoke StartupFormReceived handlers DataGen->>Hub: InvokeAsync("StartupFormCompleted",
resourceName, success) - DataGen->>Worker: Complete WaitForStartupFormAsync
with form data dictionary - Worker->>Worker: Apply startup configuration Note over Worker: Set initial delay, mode, etc.
from form data diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs index 1e2aecb..59a101a 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -1,5 +1,8 @@ +#pragma warning disable ASPIREINTERACTION001 + using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -98,4 +101,124 @@ await notify.PublishUpdateAsync(resource, state => state with }) .ExcludeFromManifest(); } + + /// + /// Configures the startup form resource with the "Configure" command and lifecycle management. + /// Call this method after + /// to wire up the form's command handler and state transitions. + /// + /// The startup form resource builder. + /// The resource builder for chaining. + public static IResourceBuilder WithStartupFormBehavior( + this IResourceBuilder builder) + { + var formResource = builder.Resource; + var form = formResource.Form; + var inputs = form.Inputs.Select(ManifestReader.ToInteractionInput).ToArray(); + + // Register the "Configure" command on the startup form resource + builder.WithCommand( + name: "projectcommander-configure", + displayName: form.Title, + executeCommand: async (context) => + { + // Check if the startup form has already been completed + if (formResource.IsCompleted) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = "Startup form has already been submitted. Restart the resource to configure again." + }; + } + + try + { + var model = context.ServiceProvider.GetRequiredService(); + var hubResource = model.Resources.OfType().SingleOrDefault(); + + if (hubResource?.Hub == null) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = "Project Commander hub is not running." + }; + } + + var interaction = context.ServiceProvider.GetRequiredService(); + + // Show the startup form prompt + var result = await interaction.PromptInputsAsync( + form.Title, + form.Description ?? "Please configure the following settings:", + inputs, + cancellationToken: context.CancellationToken); + + if (result.Canceled) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = "Configuration cancelled." + }; + } + + // Build form data dictionary + var formData = new Dictionary(); + for (var i = 0; i < inputs.Length; i++) + { + formData[inputs[i].Name] = result.Data[i].Value; + } + + // Mark the form resource as completed + formResource.MarkCompleted(formData); + + // Send form data to the project via SignalR + var groupName = formResource.ParentProject.Name; + + // Get all instances of the parent project (handles replicas) + // The SignalR group is named after the resource instance name (e.g., "datagenerator-abc123") + // We need to send to all instances that match the base resource name + await hubResource.Hub.Clients.All.SendAsync( + "ReceiveStartupForm", + groupName, + formData, + context.CancellationToken); + + // Transition the startup form resource to Running state + var notify = context.ServiceProvider.GetRequiredService(); + await notify.PublishUpdateAsync(formResource, state => state with + { + State = KnownResourceStates.Running, + Properties = [ + .. state.Properties, + new("form.completedAt", DateTime.Now.ToString("O")) + ] + }); + + return new ExecuteCommandResult { Success = true }; + } + catch (Exception ex) + { + return new ExecuteCommandResult + { + Success = false, + ErrorMessage = ex.Message + }; + } + }, + new CommandOptions + { + IconName = "Settings", + IconVariant = IconVariant.Regular, + IsHighlighted = true, + // Dynamically update command state based on whether form is completed + UpdateState = (context) => formResource.IsCompleted + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled + }); + + return builder; + } } \ No newline at end of file diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 88512c0..ed6053a 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -32,18 +32,28 @@ public async Task Identify([ResourceName] string resourceName) //, ProjectComman await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); - // Check if this resource has a startup form and notify the client + // Check if this resource has a startup form resource and notify the client var baseResourceName = resourceNameParser.GetBaseResourceName(resourceName); var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); if (resource != null) { - var startupFormAnnotation = resource.Annotations.OfType().FirstOrDefault(); - if (startupFormAnnotation != null && !startupFormAnnotation.IsCompleted) + var startupFormAnnotation = resource.Annotations.OfType().FirstOrDefault(); + if (startupFormAnnotation != null && !startupFormAnnotation.StartupFormResource.IsCompleted) { // Notify client that a startup form is required - await Clients.Caller.SendAsync("StartupFormRequired", startupFormAnnotation.Form.Title); - logger.LogInformation("{ResourceName} requires startup form: {Title}", resourceName, startupFormAnnotation.Form.Title); + var form = startupFormAnnotation.StartupFormResource.Form; + await Clients.Caller.SendAsync("StartupFormRequired", form.Title); + logger.LogInformation("{ResourceName} requires startup form: {Title}", resourceName, form.Title); + } + else if (startupFormAnnotation != null && startupFormAnnotation.StartupFormResource.IsCompleted) + { + // Form already completed, send the data to the newly connected client + logger.LogInformation("{ResourceName} startup form already completed, sending cached data", resourceName); + await Clients.Caller.SendAsync( + "ReceiveStartupForm", + baseResourceName, + startupFormAnnotation.StartupFormResource.FormData); } } } @@ -59,17 +69,20 @@ public async Task StartupFormCompleted([ResourceName] string resourceName, bool { logger.LogInformation("{ResourceName} startup form completed: Success={Success}", resourceName, success); - // Find the resource and update the annotation + // Find the resource and update the StartupFormResource var baseResourceName = resourceNameParser.GetBaseResourceName(resourceName); var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); if (resource != null) { - var annotation = resource.Annotations.OfType().FirstOrDefault(); + var annotation = resource.Annotations.OfType().FirstOrDefault(); if (annotation != null) { - annotation.IsCompleted = success; - annotation.ErrorMessage = success ? null : errorMessage; + // Note: The StartupFormResource state is managed by the command handler in + // DistributedApplicationBuilderExtensions.WithStartupFormBehavior() + // This callback is mainly for logging and notifying other clients + logger.LogDebug("StartupFormResource '{FormName}' completion acknowledged", + annotation.StartupFormResource.Name); } } diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 3a06bf3..c038d42 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -23,36 +23,57 @@ public static class ResourceBuilderProjectCommanderExtensions { /// /// Registers project commands from a projectcommander.json manifest file located in the project directory. - /// If no manifest file exists, no commands are registered. - /// This method can be combined with to add additional commands. + /// If the manifest defines a startup form, a is created that the project + /// can wait on using . /// /// The type of project resource. /// The resource builder. - /// The resource builder for chaining. - public static IResourceBuilder WithProjectManifest(this IResourceBuilder builder) + /// + /// A tuple containing the project resource builder and an optional startup form resource builder. + /// The startup form resource is null if no startup form is defined in the manifest. + /// + public static (IResourceBuilder Project, IResourceBuilder? StartupForm) WithProjectManifest( + this IResourceBuilder builder) where T : ProjectResource { var projectPath = GetProjectDirectory(builder.Resource); - var manifest = ManifestReader.ReadManifest(projectPath); if (manifest == null) { // No manifest found, nothing to register - return builder; + return (builder, null); } - // Store startup form in annotation if present and register the configure command + IResourceBuilder? startupFormBuilder = null; + + // Create startup form resource if present if (manifest.StartupForm != null) { - var startupFormAnnotation = new StartupFormAnnotation(manifest.StartupForm); - builder.WithAnnotation(startupFormAnnotation); + var startupFormResource = new StartupFormResource( + $"{builder.Resource.Name}-config", + manifest.StartupForm, + builder.Resource); + + // Add annotation to link parent project to startup form resource + builder.WithAnnotation(new StartupFormResourceAnnotation(startupFormResource)); // Add environment variable so the client knows it needs to wait for startup form builder.WithEnvironment("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED", "true"); - // Add a "Configure" command to trigger the startup form prompt - RegisterStartupFormCommand(builder, startupFormAnnotation); + // Add the startup form resource to the application model + startupFormBuilder = builder.ApplicationBuilder.AddResource(startupFormResource) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "StartupForm", + State = StartupFormResource.WaitingForConfigurationState, + Properties = [ + new(CustomResourceKnownProperties.Source, $"Startup form for {builder.Resource.Name}"), + new("form.title", manifest.StartupForm.Title), + new("form.inputCount", manifest.StartupForm.Inputs.Count.ToString()) + ] + }) + .ExcludeFromManifest(); } // Register commands from manifest @@ -66,108 +87,10 @@ public static IResourceBuilder WithProjectManifest(this IResourceBuilder - /// Registers the startup form command for a resource with a startup form. - /// The command is disabled after successful submission and requires a resource restart to re-enable. - /// - private static void RegisterStartupFormCommand(IResourceBuilder builder, StartupFormAnnotation annotation) - where T : ProjectResource - { - var form = annotation.Form; - var inputs = form.Inputs.Select(ManifestReader.ToInteractionInput).ToArray(); - - builder.WithCommand( - name: "projectcommander-configure", - displayName: form.Title, - executeCommand: async (context) => - { - // Check if the startup form has already been completed - if (annotation.IsCompleted) - { - return new ExecuteCommandResult - { - Success = false, - ErrorMessage = "Startup form has already been submitted. Restart the resource to configure again." - }; - } - - try - { - var model = context.ServiceProvider.GetRequiredService(); - var hubResource = model.Resources.OfType().SingleOrDefault(); - - if (hubResource?.Hub == null) - { - return new ExecuteCommandResult - { - Success = false, - ErrorMessage = "Project Commander hub is not running." - }; - } - - var interaction = context.ServiceProvider.GetRequiredService(); - - // Show the startup form prompt - var result = await interaction.PromptInputsAsync( - form.Title, - form.Description ?? "Please configure the following settings:", - inputs, - cancellationToken: context.CancellationToken); - - if (result.Canceled) - { - return new ExecuteCommandResult - { - Success = false, - ErrorMessage = "Configuration cancelled." - }; - } - - // Build form data dictionary - var formData = new Dictionary(); - 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); - - // Update annotation to mark as completed - annotation.FormData = formData; - annotation.IsCompleted = true; - - return new ExecuteCommandResult { Success = true }; - } - catch (Exception ex) - { - return new ExecuteCommandResult - { - Success = false, - ErrorMessage = ex.Message - }; - } - }, - new CommandOptions - { - IconName = "Settings", - IconVariant = IconVariant.Regular, - IsHighlighted = true, - // Dynamically update command state based on whether form is completed - UpdateState = (context) => annotation.IsCompleted - ? ResourceCommandState.Disabled - : ResourceCommandState.Enabled - }); + return (builder, startupFormBuilder); } /// diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs deleted file mode 100644 index a601f71..0000000 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Aspire.Hosting.ApplicationModel; - -namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; - -/// -/// Annotation that stores startup form configuration for a project resource. -/// When present, the project will be held in a "Waiting for Configuration" state -/// until the startup form is completed by the developer. -/// -public sealed class StartupFormAnnotation : IResourceAnnotation -{ - /// - /// Creates a new StartupFormAnnotation with the specified form definition. - /// - /// The startup form definition from the manifest. - public StartupFormAnnotation(StartupFormDefinition form) - { - Form = form ?? throw new ArgumentNullException(nameof(form)); - } - - /// - /// The startup form definition. - /// - public StartupFormDefinition Form { get; } - - /// - /// Whether the startup form has been completed by the user. - /// - public bool IsCompleted { get; set; } - - /// - /// The form data submitted by the user, keyed by input name. - /// - public Dictionary FormData { get; set; } = new(); - - /// - /// Optional error message if the form submission failed validation. - /// - public string? ErrorMessage { get; set; } -} diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResource.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResource.cs new file mode 100644 index 0000000..f8ac639 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResource.cs @@ -0,0 +1,59 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Represents a startup form as an Aspire resource. Projects can use WaitFor +/// to wait for this resource to transition to Running state (form completed). +/// +public sealed class StartupFormResource : Resource +{ + /// + /// Custom resource state indicating the form is waiting for user input. + /// + public const string WaitingForConfigurationState = "WaitingForConfiguration"; + + /// + /// Creates a new StartupFormResource. + /// + /// Resource name (typically "{parentName}-config"). + /// The startup form definition from the manifest. + /// The project resource this form belongs to. + public StartupFormResource(string name, StartupFormDefinition form, IResource parentProject) + : base(name) + { + Form = form ?? throw new ArgumentNullException(nameof(form)); + ParentProject = parentProject ?? throw new ArgumentNullException(nameof(parentProject)); + } + + /// + /// The startup form definition containing title, description, and inputs. + /// + public StartupFormDefinition Form { get; } + + /// + /// The project resource this startup form belongs to. + /// + public IResource ParentProject { get; } + + /// + /// Whether the startup form has been completed by the user. + /// + public bool IsCompleted { get; private set; } + + /// + /// The form data submitted by the user, keyed by input name. + /// Only populated after is called. + /// + public Dictionary FormData { get; private set; } = new(); + + /// + /// Marks the startup form as completed with the provided form data. + /// + /// The form data submitted by the user. + internal void MarkCompleted(Dictionary formData) + { + FormData = formData ?? throw new ArgumentNullException(nameof(formData)); + IsCompleted = true; + } +} diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResourceAnnotation.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResourceAnnotation.cs new file mode 100644 index 0000000..56991bf --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormResourceAnnotation.cs @@ -0,0 +1,24 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; + +/// +/// Annotation applied to a project resource to link it to its associated . +/// This allows the SignalR hub to find the form resource when a project connects. +/// +public sealed class StartupFormResourceAnnotation : IResourceAnnotation +{ + /// + /// Creates a new StartupFormResourceAnnotation. + /// + /// The startup form resource for this project. + public StartupFormResourceAnnotation(StartupFormResource startupFormResource) + { + StartupFormResource = startupFormResource ?? throw new ArgumentNullException(nameof(startupFormResource)); + } + + /// + /// The startup form resource associated with the parent project. + /// + public StartupFormResource StartupFormResource { get; } +} diff --git a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs index ae5551c..2dcce00 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -24,6 +24,7 @@ internal sealed class AspireProjectCommanderClientWorker( private HubConnection? _hub; private string? _aspireResourceName; + private string? _baseResourceName; /// public bool IsStartupFormRequired => startupFormService.IsStartupFormRequired; @@ -69,9 +70,16 @@ await Task.Run(async () => } }); - // Wire up startup form handler - _hub.On>("ReceiveStartupForm", async (formData) => + // Wire up startup form handler - now includes resource name for filtering + _hub.On>("ReceiveStartupForm", async (resourceName, formData) => { + // Only process if this message is for this project (or broadcast) + if (resourceName != _baseResourceName && !string.IsNullOrEmpty(resourceName)) + { + logger.LogDebug("Ignoring startup form for different resource: {ResourceName}", resourceName); + return; + } + logger.LogInformation("Received startup form data with {Count} fields", formData.Count); bool success = true; @@ -134,6 +142,7 @@ await Task.Run(async () => var aspireServiceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME")!; var aspireResourceSuffix = Environment.GetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES")!.Split("=")[1]; _aspireResourceName = $"{aspireServiceName}-{aspireResourceSuffix}"; + _baseResourceName = aspireServiceName; await _hub.InvokeAsync("Identify", _aspireResourceName, stoppingToken); diff --git a/copilot.md b/copilot.md index 21d5625..d4db851 100644 --- a/copilot.md +++ b/copilot.md @@ -14,7 +14,7 @@ This repo contains .NET Aspire libraries that enable custom project commands fro - Tests: ProjectCommander.Tests ## Target framework and SDK -- Uses .NET SDK 9.0.100 (global.json). +- Uses .NET SDK 10.0.100 (global.json). - Solutions: ProjectCommander.sln (primary), Packages.sln (packaging focus). ## Build, test, and run From ed2174e195f82bc943b779ef0cd69b186e002eaa Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 16:33:42 -0500 Subject: [PATCH 11/16] fix eventing --- Sample/ProjectCommander.AppHost/Program.cs | 1 + ...DistributedApplicationBuilderExtensions.cs | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index b860894..fa23071 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -29,6 +29,7 @@ { datageneratorConfig.WithStartupFormBehavior(); datagenerator.WaitFor(datageneratorConfig); + datageneratorConfig.WithParentRelationship(datagenerator); } builder.AddProject("consumer") diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs index 59a101a..f58f931 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -2,6 +2,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -116,6 +117,25 @@ public static IResourceBuilder WithStartupFormBehavior( var form = formResource.Form; var inputs = form.Inputs.Select(ManifestReader.ToInteractionInput).ToArray(); + // Subscribe to InitializeResourceEvent to participate in Aspire's lifecycle. + // This is required for WaitFor to work correctly with custom resources. + // We don't transition to Running here - we stay in WaitingForConfiguration + // until the user completes the form. + builder.ApplicationBuilder.Eventing.Subscribe(formResource, async (e, ct) => + { + var notify = e.Services.GetRequiredService(); + var logger = e.Services.GetRequiredService().GetLogger(formResource); + + logger.LogInformation("Startup form '{FormTitle}' initialized - waiting for user configuration", form.Title); + + // Keep the resource in WaitingForConfiguration state + // The state was already set via WithInitialState, but we update the timestamp + await notify.PublishUpdateAsync(formResource, state => state with + { + CreationTimeStamp = DateTime.Now + }); + }); + // Register the "Configure" command on the startup form resource builder.WithCommand( name: "projectcommander-configure", @@ -186,17 +206,41 @@ await hubResource.Hub.Clients.All.SendAsync( formData, context.CancellationToken); - // Transition the startup form resource to Running state + // Get the eventing service from the runtime service provider + var eventing = context.ServiceProvider.GetRequiredService(); + + // Publish BeforeResourceStartedEvent to signal we're about to start. + // This is required for Aspire to properly track the resource lifecycle. + await eventing.PublishAsync( + new BeforeResourceStartedEvent(formResource, context.ServiceProvider), + context.CancellationToken); + + // Transition the startup form resource to Running state. var notify = context.ServiceProvider.GetRequiredService(); await notify.PublishUpdateAsync(formResource, state => state with { State = KnownResourceStates.Running, + StartTimeStamp = DateTime.Now, Properties = [ .. state.Properties, new("form.completedAt", DateTime.Now.ToString("O")) ] }); + // For custom resources without a process (like StartupFormResource), we must manually + // publish ResourceReadyEvent. Aspire's automatic ResourceReadyEvent publishing only + // works for built-in resource types (Container, Project, Executable) that have + // actual processes Aspire monitors. This is what unblocks WaitFor dependents. + await eventing.PublishAsync( + new ResourceReadyEvent(formResource, context.ServiceProvider), + context.CancellationToken); + + // Now transition to Finished to indicate this is a completed one-time task + await notify.PublishUpdateAsync(formResource, state => state with + { + State = KnownResourceStates.Finished + }); + return new ExecuteCommandResult { Success = true }; } catch (Exception ex) From de1ecbb5e2976c1c72c97bca25d5cbd1b2a1697c Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 16:49:24 -0500 Subject: [PATCH 12/16] simplify API and update readme --- README.md | 102 +++++++++++++++--- Sample/ProjectCommander.AppHost/Program.cs | 11 +- ...sourceBuilderProjectCommanderExtensions.cs | 24 ++--- copilot.md | 35 ++++++ 4 files changed, 138 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3752521..510bb46 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,26 @@ Aspire Project Commander is a set of packages that lets you send simple string c |Integration|`Nivot.Aspire.ProjectCommander`|![NuGet Version](https://img.shields.io/nuget/v/Nivot.Aspire.ProjectCommander)| |Hosting|`Nivot.Aspire.Hosting.ProjectCommander`|![NuGet Version](https://img.shields.io/nuget/v/Nivot.Aspire.Hosting.ProjectCommander)| +## Installation + +### AppHost Project + +Add the hosting package to your Aspire AppHost project: + +```bash +cd YourAppHost +dotnet add package Nivot.Aspire.Hosting.ProjectCommander +``` + +### Client Projects + +Add the integration package to each project that will receive commands or use startup forms: + +```bash +cd YourProject +dotnet add package Nivot.Aspire.ProjectCommander +``` + ## Features - **Custom Project Commands** - Send commands from the Aspire Dashboard to running projects @@ -102,23 +122,21 @@ var builder = DistributedApplication.CreateBuilder(args); var commander = builder.AddAspireProjectCommander(); -// WithProjectManifest returns a tuple: (project builder, optional startup form resource) -var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") +var datagenerator = builder.AddProject("datagenerator") .WithReference(commander) .WaitFor(commander) .WithProjectManifest(); // Reads commands and startup form from projectcommander.json -// If the project has a startup form, configure it and make the project wait for it -if (datageneratorConfig is not null) -{ - datageneratorConfig.WithStartupFormBehavior(); - datagenerator.WaitFor(datageneratorConfig); // Project won't start until form is completed -} - builder.Build().Run(); ``` -The startup form appears as a separate resource in the Aspire dashboard with state `WaitingForConfiguration`. +The `WithProjectManifest()` extension method automatically: +- Reads commands from `projectcommander.json` and registers them in the dashboard +- If a `startupForm` is defined, creates a `StartupFormResource` that appears in the dashboard +- Configures `WaitFor` so the project doesn't start until the form is completed +- Sets up parent-child relationship for visual grouping in the dashboard + +The startup form appears as a separate resource in the Aspire Dashboard with state `WaitingForConfiguration`. The project is blocked by Aspire's `WaitFor` until the developer clicks "Configure" and submits the form, at which point the form resource transitions to `Running` and the project starts. @@ -240,12 +258,72 @@ public sealed class MyProjectCommands(IAspireProjectCommanderClient commander, I You can use both `WithProjectManifest()` and `WithProjectCommands()` together - the commands will be merged: ```csharp -builder.AddProject("datagenerator") +var datagenerator = builder.AddProject("datagenerator") .WithReference(commander) - .WithProjectManifest() // Commands from manifest + .WaitFor(commander) + .WithProjectManifest() // Commands from manifest + startup form handling .WithProjectCommands(new("extra", "Extra Command")); // Additional code-defined command ``` +## Quick Start Example + +Here's a complete minimal example: + +**AppHost/Program.cs:** +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var commander = builder.AddAspireProjectCommander(); + +builder.AddProject("myservice") + .WithReference(commander) + .WaitFor(commander) + .WithProjectManifest(); + +builder.Build().Run(); +``` + +**MyService/projectcommander.json:** +```json +{ + "$schema": "https://raw.githubusercontent.com/oising/AspireProjectCommander/main/schemas/projectcommander-v1.schema.json", + "version": "1.0", + "commands": [ + { "name": "ping", "displayName": "Ping" } + ] +} +``` + +**MyService/Program.cs:** +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddAspireProjectCommanderClient(); +builder.Services.AddHostedService(); + +var app = builder.Build(); +app.MapDefaultEndpoints(); +app.Run(); +``` + +**MyService/CommandHandler.cs:** +```csharp +public sealed class CommandHandler(IAspireProjectCommanderClient commander, ILogger logger) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + commander.CommandReceived += (command, args, sp) => + { + logger.LogInformation("Received: {Command}", command); + return Task.CompletedTask; + }; + + await Task.Delay(Timeout.Infinite, stoppingToken); + } +} +``` + ## Sample See the `Sample` folder for an Aspire example that allows you to signal a data generator project that is writing messages into an emulator instance of Azure Event Hubs. diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index fa23071..7c13714 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -16,22 +16,13 @@ var client = datahub.AddConsumerGroup("client"); -// WithProjectManifest now returns a tuple with the project and optional startup form resource -var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") +var datagenerator = builder.AddProject("datagenerator") .WithReference(datahub) .WithReference(commander) .WaitFor(commander) .WaitFor(datahub) .WithProjectManifest(); // Reads commands and startup form from projectcommander.json -// If the project has a startup form, configure it and make the project wait for it -if (datageneratorConfig is not null) -{ - datageneratorConfig.WithStartupFormBehavior(); - datagenerator.WaitFor(datageneratorConfig); - datageneratorConfig.WithParentRelationship(datagenerator); -} - builder.AddProject("consumer") .WithReference(commander) .WaitFor(commander) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index c038d42..b0a710a 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -23,16 +23,13 @@ public static class ResourceBuilderProjectCommanderExtensions { /// /// Registers project commands from a projectcommander.json manifest file located in the project directory. - /// If the manifest defines a startup form, a is created that the project - /// can wait on using . + /// If the manifest defines a startup form, a is created and automatically + /// configured so the project waits for it to be completed before starting. /// /// The type of project resource. /// The resource builder. - /// - /// A tuple containing the project resource builder and an optional startup form resource builder. - /// The startup form resource is null if no startup form is defined in the manifest. - /// - public static (IResourceBuilder Project, IResourceBuilder? StartupForm) WithProjectManifest( + /// The resource builder for chaining. + public static IResourceBuilder WithProjectManifest( this IResourceBuilder builder) where T : ProjectResource { @@ -42,11 +39,9 @@ public static (IResourceBuilder Project, IResourceBuilder? startupFormBuilder = null; - // Create startup form resource if present if (manifest.StartupForm != null) { @@ -62,7 +57,7 @@ public static (IResourceBuilder Project, IResourceBuilder Project, IResourceBuilder Project, IResourceBuilder diff --git a/copilot.md b/copilot.md index d4db851..63ef643 100644 --- a/copilot.md +++ b/copilot.md @@ -39,3 +39,38 @@ This repo contains .NET Aspire libraries that enable custom project commands fro ## Tests - Keep new tests alongside ProjectCommander.Tests. - Favor integration-style tests when validating end-to-end command flow. + +## Aspire custom resource lifecycle patterns + +### WaitFor and ResourceReadyEvent +When creating custom Aspire resources that other resources can `WaitFor`: + +1. **Subscribe to `InitializeResourceEvent`** - Custom resources must opt-in to Aspire's lifecycle by subscribing to this event. Without this, the resource isn't tracked by Aspire's orchestrator. + +2. **Publish `BeforeResourceStartedEvent`** before transitioning to `Running` state - This signals to Aspire that the resource is about to start. + +3. **Publish `ResourceReadyEvent` for process-less custom resources** - Aspire automatically publishes `ResourceReadyEvent` for built-in types (Container, Project, Executable) that have actual processes. For custom resources without a process (like `StartupFormResource`), you must manually publish this event to unblock `WaitFor` dependents. + +4. **Resolve services from runtime `ServiceProvider`, not build-time builder** - When publishing events from command handlers or callbacks that execute at runtime: + ```csharp + // WRONG - captured at build time, may not work at runtime + await builder.ApplicationBuilder.Eventing.PublishAsync(...); + + // CORRECT - resolved at runtime + var eventing = context.ServiceProvider.GetRequiredService(); + await eventing.PublishAsync(...); + ``` + +### State machine for one-time configuration resources +For resources like startup forms that block until user input: +1. Initial state: Custom state (e.g., `WaitingForConfiguration`) +2. After user completes form: `Running` → publish `ResourceReadyEvent` → `Finished` + +### Key Aspire eventing types +- `InitializeResourceEvent` - First event fired for any resource +- `BeforeResourceStartedEvent` - Just before execution begins +- `ResourceReadyEvent` - Unblocks dependents waiting via `WaitFor` +- Namespace: `Aspire.Hosting.Eventing` + +### Reference documentation +- Aspire app model spec: https://github.com/dotnet/aspire/blob/main/docs/specs/appmodel.md From a179bc9e64f05bdee067e9af9f3eaf9804b492cf Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 17:13:34 -0500 Subject: [PATCH 13/16] Update ProjectCommander.Tests/ProjectCommander.Tests.csproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ProjectCommander.Tests/ProjectCommander.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProjectCommander.Tests/ProjectCommander.Tests.csproj b/ProjectCommander.Tests/ProjectCommander.Tests.csproj index ca40d6b..f864025 100644 --- a/ProjectCommander.Tests/ProjectCommander.Tests.csproj +++ b/ProjectCommander.Tests/ProjectCommander.Tests.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 14ad1b2a75683667eceb93b10e287042297bcc35 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 20 Feb 2026 17:13:55 -0500 Subject: [PATCH 14/16] Update Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs index 2e9124f..5529f83 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs @@ -13,7 +13,7 @@ public string GetBaseResourceName(string resourceName) throw new ArgumentException("Resource name cannot be null or empty.", nameof(resourceName)); } - // Split on first hyphen to extract base name + // Split on last hyphen to extract base name // Example: "datagenerator-abc123" -> "datagenerator" if (!resourceName.Contains('-')) { From 0736ee0c8e6f2ea64ef598a7ca53293ce6f003dd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:20:05 -0500 Subject: [PATCH 15/16] Fix CHANGELOG: WithProjectManifest returns IResourceBuilder, not a tuple (#11) * Initial plan * Fix CHANGELOG to accurately document WithProjectManifest return type Co-authored-by: oising <1844001+oising@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oising <1844001+oising@users.noreply.github.com> --- CHANGELOG.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e54b2bc..5804eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Projects can now define their own commands using a `projectcommander.json` manifest file placed in the project root directory. This enables projects to be self-describing and portable, without requiring command definitions in the AppHost. **New extension method:** -- `WithProjectManifest()` - Reads commands and startup forms from the project's `projectcommander.json` file - - **Breaking change:** Now returns `(IResourceBuilder, IResourceBuilder?)` tuple +- `WithProjectManifest()` - Reads commands and startup forms from the project's `projectcommander.json` file; returns `IResourceBuilder` for chaining **Manifest features:** - Define commands with name, display name, description, and icon @@ -35,24 +34,16 @@ Startup forms are now represented as first-class Aspire resources. This enables **How it works:** 1. Define a `startupForm` section in your `projectcommander.json` -2. Call `WithProjectManifest()` which returns a tuple with the optional `StartupFormResource` -3. Call `WithStartupFormBehavior()` on the form resource to register the Configure command -4. Use `WaitFor(startupFormResource)` to block the project until the form is completed -5. The form resource appears in the dashboard with state `WaitingForConfiguration` -6. When the user submits the form, the resource transitions to `Running` and the project starts +2. Call `WithProjectManifest()` — the startup form resource is automatically created, wired up, and the project is configured to wait for it +3. The form resource appears in the dashboard with state `WaitingForConfiguration` +4. When the user submits the form, the resource transitions to `Running` and the project starts **Example:** ```csharp -var (datagenerator, datageneratorConfig) = builder.AddProject("datagenerator") +builder.AddProject("datagenerator") .WithReference(commander) .WaitFor(commander) .WithProjectManifest(); - -if (datageneratorConfig is not null) -{ - datageneratorConfig.WithStartupFormBehavior(); - datagenerator.WaitFor(datageneratorConfig); -} ``` **Client-side:** @@ -83,7 +74,7 @@ You can now use both `WithProjectManifest()` and `WithProjectCommands()` togethe | File | Changes | |------|---------| -| `ResourceBuilderProjectCommanderExtensions.cs` | `WithProjectManifest()` now returns tuple with `StartupFormResource` | +| `ResourceBuilderProjectCommanderExtensions.cs` | `WithProjectManifest()` automatically wires up the startup form resource | | `DistributedApplicationBuilderExtensions.cs` | Added `WithStartupFormBehavior()` extension | | `ProjectCommanderHub.cs` | Uses `StartupFormResourceAnnotation`, sends cached form data on connect | | `IAspireProjectCommanderClient.cs` | Startup form interface members | From bfd8151dae958884d6ee3a5a527d61ce40614f31 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:21:32 -0500 Subject: [PATCH 16/16] Fix thread-unsafe field access in StartupFormService (#12) * Initial plan * Fix thread-safe access to StartupFormService fields using volatile Co-authored-by: oising <1844001+oising@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oising <1844001+oising@users.noreply.github.com> Co-authored-by: Oisin Grehan --- Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs b/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs index 46b1cf7..ed8c797 100644 --- a/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs +++ b/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs @@ -10,9 +10,9 @@ internal sealed class StartupFormService : IStartupFormService private readonly ILogger _logger; private readonly TaskCompletionSource> _completionSource = new(); - private bool _isStartupFormRequired; - private bool _isStartupFormCompleted; - private Dictionary? _startupFormData; + private volatile bool _isStartupFormRequired; + private volatile bool _isStartupFormCompleted; + private volatile Dictionary? _startupFormData; public StartupFormService(ILogger logger) { @@ -38,8 +38,8 @@ public void CompleteStartupForm(Dictionary formData) throw new ArgumentNullException(nameof(formData)); } - _isStartupFormCompleted = true; _startupFormData = formData; + _isStartupFormCompleted = true; _completionSource.TrySetResult(formData); _logger.LogInformation("Startup form completed with {Count} fields", formData.Count); }