Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .aspire/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"appHostPath": "../Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj"
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
##
## Get latest from `dotnet new gitignore`

# claude agent detritus
tmpclaude-*

# dotenv files
.env

Expand Down
113 changes: 113 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<T>()` - 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<Projects.DataGenerator>("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<Projects.DataGenerator>("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
12 changes: 12 additions & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="https://api.nuget.org/v3/index.json" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="https://api.nuget.org/v3/index.json">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
6 changes: 3 additions & 3 deletions ProjectCommander.Tests/ProjectCommander.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="9.4.1" />
<PackageReference Include="Aspire.Hosting.Testing" Version="13.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
37 changes: 37 additions & 0 deletions ProjectCommander.UnitTests/ProjectCommander.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Src\Nivot.Aspire.ProjectCommander\Nivot.Aspire.ProjectCommander.csproj" />
<ProjectReference Include="..\Src\Nivot.Aspire.Hosting.ProjectCommander\Nivot.Aspire.Hosting.ProjectCommander.csproj" />
</ItemGroup>

</Project>
38 changes: 38 additions & 0 deletions ProjectCommander.UnitTests/ResourceNameParserTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => _parser.GetBaseResourceName(input!));
}
}
140 changes: 140 additions & 0 deletions ProjectCommander.UnitTests/StartupFormServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using CommunityToolkit.Aspire.ProjectCommander;
using Microsoft.Extensions.Logging;
using Moq;

namespace ProjectCommander.UnitTests;

public class StartupFormServiceTests
{
private readonly Mock<ILogger<StartupFormService>> _mockLogger;
private readonly StartupFormService _service;

public StartupFormServiceTests()
{
_mockLogger = new Mock<ILogger<StartupFormService>>();
_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<string, string?>
{
{ "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<ArgumentNullException>(() => _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<string, string?>
{
{ "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<string, string?>
{
{ "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<OperationCanceledException>(
() => _service.WaitForStartupFormAsync(cts.Token));
}
}
6 changes: 6 additions & 0 deletions ProjectCommander.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading