Skip to content
Merged
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
24 changes: 24 additions & 0 deletions docs/cli/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ builder.AddProject<Projects.Api>("api")
Unknown verbs are treated as mutating (safe-by-default) and get a confirmation prompt unless you
override it.

## Dynamic command discovery

By default the buttons come from a curated list of the standard JasperFx verbs. To instead render a
button for **every** verb the target actually exposes — including product-specific commands (Marten's
`projections`, etc.) and your own [custom commands](/cli/writing-commands) — opt into discovery:

```cs
builder.AddProject<Projects.Api>("api")
.WithJasperFxCommands(opts => opts.DiscoverCommands = true);
```

At AppHost build time this runs `help --json` against the already-built project to read its command
catalog, then renders one button per verb (known verbs keep their curated icon/confirmation; unknown
verbs are treated as mutating). The same `IncludeMutatingCommands` / `IncludeVerbs` / `ExcludeVerbs`
gating applies, and `run`/`help` are never shown.

Discovery is **best-effort**: if the project isn't built yet, the call times out, or the output can't
be parsed, it silently falls back to the curated catalog. Because it uses `--no-build`, build the
target first (a normal `dotnet build` of your solution) for discovery to succeed.

> `help --json` is a general-purpose, machine-readable command catalog — it lists each verb's name and
> description as JSON and runs without starting the host (no database/broker connections), so it's
> cheap to call from tooling.

## How it works

The dashboard command callback runs **inside the AppHost process**, not the target application. To
Expand Down
20 changes: 20 additions & 0 deletions docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ JasperFx ships with several commands out of the box:

Commands are discovered automatically from referenced assemblies that carry the `[JasperFxTool]` attribute. Your own commands are found through assembly scanning.

## Machine-readable command catalog

`help --json` writes the command catalog to stdout as JSON — each verb's `name` and `description` —
for tooling that needs to introspect an app's commands:

```bash
dotnet run -- help --json
```

```json
[
{ "name": "check-env", "description": "Execute all environment checks against the application" },
{ "name": "describe", "description": "Writes out a description of your running application ..." }
]
```

Like `help` itself, this runs without starting the host (no database/broker connections), so it is
cheap to call. The [Aspire dashboard integration](/cli/aspire#dynamic-command-discovery) uses it to
discover a service's verbs.

## Topics

- [Writing Commands](/cli/writing-commands) -- Create synchronous and async commands
Expand Down
73 changes: 73 additions & 0 deletions src/CommandLineTests/CommandCatalogJsonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Reflection;
using System.Text.Json;
using JasperFx.CommandLine;
using JasperFx.CommandLine.Commands;
using JasperFx.CommandLine.Help;
using Shouldly;

namespace CommandLineTests;

public class CommandCatalogJsonTests
{
private static IEnumerable<Type> BuiltInCommandTypes()
{
var factory = new CommandFactory();
factory.RegisterCommands(typeof(RunCommand).GetTypeInfo().Assembly);
return factory.AllCommandTypes();
}

[Fact]
public void emits_a_json_array_of_name_and_description_objects()
{
var json = HelpCommand.ToCommandCatalogJson(BuiltInCommandTypes());

using var doc = JsonDocument.Parse(json);
doc.RootElement.ValueKind.ShouldBe(JsonValueKind.Array);

var entries = doc.RootElement.EnumerateArray().ToArray();
entries.Length.ShouldBeGreaterThan(0);
foreach (var entry in entries)
{
entry.TryGetProperty("name", out _).ShouldBeTrue();
entry.TryGetProperty("description", out _).ShouldBeTrue();
}
}

[Fact]
public void includes_the_built_in_verbs_with_their_descriptions()
{
var json = HelpCommand.ToCommandCatalogJson(BuiltInCommandTypes());
using var doc = JsonDocument.Parse(json);

var byName = doc.RootElement.EnumerateArray()
.ToDictionary(e => e.GetProperty("name").GetString()!, e => e.GetProperty("description").GetString()!);

byName.ShouldContainKey("check-env");
byName.ShouldContainKey("describe");
byName.ShouldContainKey("help");
byName["check-env"].ShouldNotBeNullOrWhiteSpace();
}

[Fact]
public void entries_are_sorted_by_command_name()
{
var json = HelpCommand.ToCommandCatalogJson(BuiltInCommandTypes());
using var doc = JsonDocument.Parse(json);

var names = doc.RootElement.EnumerateArray()
.Select(e => e.GetProperty("name").GetString()!)
.ToArray();

names.ShouldBe(names.OrderBy(x => x).ToArray());
}

[Fact]
public void empty_catalog_is_an_empty_json_array()
{
var json = HelpCommand.ToCommandCatalogJson([]);

using var doc = JsonDocument.Parse(json);
doc.RootElement.ValueKind.ShouldBe(JsonValueKind.Array);
doc.RootElement.GetArrayLength().ShouldBe(0);
}
}
55 changes: 55 additions & 0 deletions src/JasperFx.Aspire.Tests/JasperFxCommandDiscoveryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using JasperFx.Aspire;
using Shouldly;

namespace JasperFx.Aspire.Tests;

public class JasperFxCommandDiscoveryTests
{
// Mirrors the real `dotnet run -- help --json` stdout: framework noise precedes the JSON array.
private const string NoisyOutput = """
Searching 'JasperFx, Version=2.3.0.0, Culture=neutral, PublicKeyToken=null' for commands
Searching 'Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' for commands

[
{ "name": "check-env", "description": "Execute all environment checks" },
{ "name": "resources", "description": "Stateful resources" },
{ "name": "run", "description": "Start and run" },
{ "name": "help", "description": "List commands" }
]
""";

[Fact]
public void parses_command_names_from_noisy_output()
{
var names = JasperFxCommandDiscovery.ParseCatalog(NoisyOutput);

names.ShouldBe(["check-env", "resources", "run", "help"]);
}

[Fact]
public void empty_output_yields_no_names()
{
JasperFxCommandDiscovery.ParseCatalog("").ShouldBeEmpty();
JasperFxCommandDiscovery.ParseCatalog(" ").ShouldBeEmpty();
}

[Fact]
public void output_with_no_json_array_yields_no_names()
{
JasperFxCommandDiscovery.ParseCatalog("Searching for commands but it crashed").ShouldBeEmpty();
}

[Fact]
public void malformed_json_yields_no_names()
{
JasperFxCommandDiscovery.ParseCatalog("[ { \"name\": ").ShouldBeEmpty();
}

[Fact]
public void entries_without_a_name_are_skipped()
{
var names = JasperFxCommandDiscovery.ParseCatalog("""[ { "description": "no name" }, { "name": "describe" } ]""");

names.ShouldBe(["describe"]);
}
}
74 changes: 74 additions & 0 deletions src/JasperFx.Aspire.Tests/ResolveDiscoveredTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using JasperFx.Aspire;
using Shouldly;

namespace JasperFx.Aspire.Tests;

public class ResolveDiscoveredTests
{
private static readonly string[] DiscoveredVerbs =
["check-env", "describe", "codegen", "resources", "projections", "storage", "run", "help"];

private static string[] Verbs(JasperFxCommandOptions options)
=> JasperFxVerbCatalog.ResolveDiscovered(DiscoveredVerbs, options).Select(t => t.Verb).ToArray();

[Fact]
public void excludes_run_and_help_always()
{
var verbs = Verbs(new JasperFxCommandOptions { IncludeMutatingCommands = true });

verbs.ShouldNotContain("run");
verbs.ShouldNotContain("help");
}

[Fact]
public void default_keeps_only_read_only_discovered_verbs()
{
var verbs = Verbs(new JasperFxCommandOptions());

verbs.ShouldContain("check-env");
verbs.ShouldContain("describe");
verbs.ShouldContain("codegen"); // maps to read-only preview by default
verbs.ShouldNotContain("resources"); // mutating
verbs.ShouldNotContain("projections"); // mutating
verbs.ShouldNotContain("storage"); // unknown → treated as mutating
}

[Fact]
public void include_mutating_keeps_the_mutating_and_unknown_verbs()
{
var verbs = Verbs(new JasperFxCommandOptions { IncludeMutatingCommands = true });

verbs.ShouldContain("resources");
verbs.ShouldContain("projections");
verbs.ShouldContain("storage"); // unknown product-specific verb surfaces when mutating is opted in
}

[Fact]
public void unknown_verbs_are_treated_as_mutating()
{
var template = JasperFxVerbCatalog.ResolveDiscovered(["storage"],
new JasperFxCommandOptions { IncludeMutatingCommands = true }).Single();

template.Verb.ShouldBe("storage");
template.Mutating.ShouldBeTrue();
template.ConfirmationMessage.ShouldNotBeNull();
}

[Fact]
public void include_verbs_is_an_explicit_allow_list()
{
var options = new JasperFxCommandOptions();
options.IncludeVerbs.Add("resources");

Verbs(options).ShouldBe(["resources"]);
}

[Fact]
public void exclude_verbs_removes_from_the_selection()
{
var options = new JasperFxCommandOptions { IncludeMutatingCommands = true };
options.ExcludeVerbs.Add("projections");

Verbs(options).ShouldNotContain("projections");
}
}
19 changes: 18 additions & 1 deletion src/JasperFx.Aspire/JasperFxAspireExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,31 @@ public static IResourceBuilder<T> WithJasperFxCommands<T>(
var options = new JasperFxCommandOptions();
configure?.Invoke(options);

foreach (var template in JasperFxVerbCatalog.Resolve(options))
foreach (var template in ResolveTemplates(builder.Resource, options))
{
RegisterCommand(builder, template, options.OverrideFor(template.Verb));
}

return builder;
}

private static IEnumerable<JasperFxCommandTemplate> ResolveTemplates(
IResource resource, JasperFxCommandOptions options)
{
// Opt-in dynamic discovery: ask the target what verbs it actually has (picks up product-specific
// and custom commands). Best-effort — any failure falls back to the curated catalog below.
if (options.DiscoverCommands && resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata))
{
var discovered = JasperFxCommandDiscovery.Discover(projectMetadata.ProjectPath, options.DiscoveryTimeout);
if (discovered != null)
{
return JasperFxVerbCatalog.ResolveDiscovered(discovered, options);
}
}

return JasperFxVerbCatalog.Resolve(options);
}

/// <summary>
/// Add a single JasperFx verb as a dashboard button, with optional fixed arguments. Works for
/// the standard verbs and for product-specific or user-defined commands (unknown verbs are
Expand Down
Loading
Loading