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..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/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/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..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/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..78029a2 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); @@ -19,12 +21,11 @@ .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..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/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/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..88512c0 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,69 @@ 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 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) + { + // 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 = resourceNameParser.GetBaseResourceName(resourceName); + 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/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..3a06bf3 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,182 @@ 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 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. + /// 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 + }); + } + + /// + /// 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 +212,7 @@ public static IResourceBuilder WithProjectCommands( { builder.WithCommand(command.Name, command.DisplayName, async (context) => { + bool success = false; string errorMessage = string.Empty; @@ -49,8 +221,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..2e9124f --- /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 first 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/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 f3478ec..ae5551c 100644 --- a/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs +++ b/Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs @@ -10,40 +10,57 @@ 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; + + /// + 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 +69,73 @@ 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) + { + 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}"; + + await _hub.InvokeAsync("Identify", _aspireResourceName, stoppingToken); // block until shutdown / stop await Task.Delay(Timeout.Infinite, stoppingToken); @@ -68,10 +143,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..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; + } +} 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/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" + } + ] + } + ] +}