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/.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..5804eeb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,135 @@ +# 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; returns `IResourceBuilder` for chaining + +**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 Form Resource + +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 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 `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 +builder.AddProject("datagenerator") + .WithReference(commander) + .WaitFor(commander) + .WithProjectManifest(); +``` + +**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 + +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 | +| `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` | `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 | +| `AspireProjectCommanderClientWorker.cs` | Handles new `ReceiveStartupForm` message format | + +### 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/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..f864025 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/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..3348107 --- /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-service")] + [InlineData("singlename", "singlename")] + [InlineData("resource-with-multiple-hyphens-123", "resource-with-multiple-hyphens")] + 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..a48b609 --- /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(); + await cts.CancelAsync(); + + // 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/README.md b/README.md index 19fed78..510bb46 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,184 @@ 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 +- **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 | + +### Using the Manifest in AppHost + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var commander = builder.AddAspireProjectCommander(); + +var datagenerator = builder.AddProject("datagenerator") + .WithReference(commander) + .WaitFor(commander) + .WithProjectManifest(); // Reads commands and startup form from projectcommander.json + +builder.Build().Run(); +``` + +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. + +### 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, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Get startup form data (returns immediately since form was already completed before project started) + 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 (New!) -Some people may prefer to stream resource logs in a terminal window. See the `SpiraLog` sample in the source. +## Remote Resource Log Viewing +Some people may prefer to stream resource logs in a terminal window. See the `SpiraLog` sample in the source. -## Example +## Code-Based Commands (Original Approach) -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. +You can still define commands directly in the AppHost using `WithProjectCommands`: ### AppHost Hosting @@ -34,11 +200,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 +232,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 +252,78 @@ 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 +var datagenerator = builder.AddProject("datagenerator") + .WithReference(commander) + .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/Consumer/Consumer.csproj b/Sample/Consumer/Consumer.csproj index 66f4392..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 66f4392..a8d663d 100644 --- a/Sample/DataGenerator/DataGenerator.csproj +++ b/Sample/DataGenerator/DataGenerator.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/DataGenerator/Program.cs b/Sample/DataGenerator/Program.cs index 5844c08..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,24 +34,56 @@ 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"; + } + } - aspire.CommandReceived += (commandName, sp) => + await Task.Run(async () => + { + 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; + 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; @@ -60,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 243a80b..7c13714 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); @@ -14,17 +16,16 @@ var client = datahub.AddConsumerGroup("client"); -builder.AddProject("datagenerator") +var datagenerator = builder.AddProject("datagenerator") .WithReference(datahub) .WithReference(commander) .WaitFor(commander) .WaitFor(datahub) - .WithProjectCommands( - new("slow", "Go Slow"), - new("fast", "Go Fast")); + .WithProjectManifest(); // Reads commands and startup form from projectcommander.json 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..d3181dc 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/Sample/sequenceDiagram.mmd b/Sample/sequenceDiagram.mmd index 7b825a8..ba45140 100644 --- a/Sample/sequenceDiagram.mmd +++ b/Sample/sequenceDiagram.mmd @@ -1,36 +1,92 @@ 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 StartupFormResource
(e.g., "datagenerator-config") + AppHost->>AppHost: Attach StartupFormResourceAnnotation
to parent project + AppHost->>AppHost: Set PROJECTCOMMANDER_STARTUP_FORM_REQUIRED=true + 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: 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 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 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 - Note over DataGen: hub.On("ReceiveCommand", handler) - + DataGen->>DataGen: Register "ReceiveStartupForm" handler + Note over DataGen: hub.On("ReceiveStartupForm", handler) + + DataGen->>DataGen: ReceiveStartupForm(resourceName, formData) + DataGen->>DataGen: Invoke StartupFormReceived handlers + DataGen->>Hub: InvokeAsync("StartupFormCompleted",
resourceName, success) + + 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 +94,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/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/DistributedApplicationBuilderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs index 1e2aecb..f58f931 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -1,5 +1,9 @@ +#pragma warning disable ASPIREINTERACTION001 + using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -98,4 +102,167 @@ 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(); + + // 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", + 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); + + // 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) + { + 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/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/ManifestReader.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs new file mode 100644 index 0000000..3efe2fe --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs @@ -0,0 +1,100 @@ +#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); + var manifest = JsonSerializer.Deserialize(json, JsonOptions); + + if (manifest is null) + { + throw new JsonException($"Failed to deserialize project command manifest from '{manifestPath}'."); + } + + return manifest; + } + + /// + /// 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/Nivot.Aspire.Hosting.ProjectCommander.csproj b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj index df83b10..2944be0 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 + + + + @@ -16,11 +20,11 @@ - - + + all runtime; build; native; contentfiles; analyzers - +
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 d786c60..ed6053a 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; @@ -11,19 +12,82 @@ 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. + /// 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) + public async Task Identify([ResourceName] string resourceName) //, ProjectCommand[]? commands = null) { logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); + + // 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.StartupFormResource.IsCompleted) + { + // Notify client that a startup form is required + 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); + } + } + } + + /// + /// 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 StartupFormResource + var baseResourceName = resourceNameParser.GetBaseResourceName(resourceName); + var resource = model.Resources.FirstOrDefault(r => r.Name == baseResourceName); + + if (resource != null) + { + var annotation = resource.Annotations.OfType().FirstOrDefault(); + if (annotation != null) + { + // 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); + } + } + + // Notify dashboard/orchestrator that startup is complete + await Clients.All.SendAsync("StartupFormStatusChanged", resourceName, success, errorMessage); } /// 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/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 503c2ae..b0a710a 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,13 +14,105 @@ 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. /// 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 and automatically + /// configured so the project waits for it to be completed before starting. + /// + /// 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; + } + + // Create startup form resource if present + if (manifest.StartupForm != null) + { + 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 the startup form resource to the application model + var 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(); + + // Automatically wire up the startup form behavior, wait dependency, and parent relationship + startupFormBuilder.WithStartupFormBehavior(); + builder.WaitFor(startupFormBuilder); + startupFormBuilder.WithParentRelationship(builder); + } + + // 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 + builder.WithProjectCommands(projectCommands); + } + + return builder; + } + + /// + /// 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. /// @@ -41,6 +135,7 @@ public static IResourceBuilder WithProjectCommands( { builder.WithCommand(command.Name, command.DisplayName, async (context) => { + bool success = false; string errorMessage = string.Empty; @@ -49,8 +144,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 = false, 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.Hosting.ProjectCommander/ResourceNameParser.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs new file mode 100644 index 0000000..5529f83 --- /dev/null +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceNameParser.cs @@ -0,0 +1,26 @@ +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 last hyphen to extract base name + // Example: "datagenerator-abc123" -> "datagenerator" + if (!resourceName.Contains('-')) + { + return resourceName; + } + + var baseName = resourceName[..resourceName.LastIndexOf("-", StringComparison.Ordinal)]; + return baseName; + } +} 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 f3478ec..2dcce00 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -10,40 +10,58 @@ 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> _commandHandlers = new(); + private readonly List, IServiceProvider, Task>> _startupFormHandlers = new(); + + private HubConnection? _hub; + private string? _aspireResourceName; + private string? _baseResourceName; + + /// + public bool IsStartupFormRequired => startupFormService.IsStartupFormRequired; + + /// + public bool IsStartupFormCompleted => startupFormService.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 + var isRequired = Environment.GetEnvironmentVariable("PROJECTCOMMANDER_STARTUP_FORM_REQUIRED") == "true"; + startupFormService.SetStartupFormRequired(isRequired); + 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) => + // Wire up command handler + _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) { @@ -52,15 +70,81 @@ await Task.Run(async () => } }); - await hub.StartAsync(stoppingToken); + // 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; + 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) + { + startupFormService.CompleteStartupForm(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); + startupFormService.SetStartupFormRequired(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}"; + _baseResourceName = aspireServiceName; + + await _hub.InvokeAsync("Identify", _aspireResourceName, stoppingToken); // block until shutdown / stop await Task.Delay(Timeout.Infinite, stoppingToken); @@ -68,10 +152,32 @@ await Task.Run(async () => }, stoppingToken); } - public event Func CommandReceived + /// + 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) + { + return await startupFormService.WaitForStartupFormAsync(cancellationToken); + } } -} \ No newline at end of file +} diff --git a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs index 599ba7d..7c425a9 100644 --- a/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs +++ b/Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs @@ -8,5 +8,48 @@ 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 + 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); +} + +/// +/// Provides extension methods for registering commands with an Aspire project commander client. +/// +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) + { + return client; + } +} + 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 5a401d9..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 @@ -12,9 +16,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 191cc01..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,13 +18,14 @@ 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) + // No way to use the TryAdd* variants as they don't cover this scenario/overloads + if (!_isRegistered) { - 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()); + _isRegistered = true; } return services; diff --git a/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs b/Src/Nivot.Aspire.ProjectCommander/StartupFormService.cs new file mode 100644 index 0000000..ed8c797 --- /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 volatile bool _isStartupFormRequired; + private volatile bool _isStartupFormCompleted; + private volatile 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)); + } + + _startupFormData = formData; + _isStartupFormCompleted = true; + _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; + } +} diff --git a/copilot.md b/copilot.md new file mode 100644 index 0000000..63ef643 --- /dev/null +++ b/copilot.md @@ -0,0 +1,76 @@ +# 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 10.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. + +## 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 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 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" + } + ] + } + ] +}