diff --git a/docs/cli/aspire.md b/docs/cli/aspire.md index eab3627..61bc254 100644 --- a/docs/cli/aspire.md +++ b/docs/cli/aspire.md @@ -96,6 +96,30 @@ builder.AddProject("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("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 diff --git a/docs/cli/index.md b/docs/cli/index.md index 23249b8..21e133c 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 diff --git a/src/CommandLineTests/CommandCatalogJsonTests.cs b/src/CommandLineTests/CommandCatalogJsonTests.cs new file mode 100644 index 0000000..544e978 --- /dev/null +++ b/src/CommandLineTests/CommandCatalogJsonTests.cs @@ -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 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); + } +} diff --git a/src/JasperFx.Aspire.Tests/JasperFxCommandDiscoveryTests.cs b/src/JasperFx.Aspire.Tests/JasperFxCommandDiscoveryTests.cs new file mode 100644 index 0000000..4121407 --- /dev/null +++ b/src/JasperFx.Aspire.Tests/JasperFxCommandDiscoveryTests.cs @@ -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"]); + } +} diff --git a/src/JasperFx.Aspire.Tests/ResolveDiscoveredTests.cs b/src/JasperFx.Aspire.Tests/ResolveDiscoveredTests.cs new file mode 100644 index 0000000..2e4d525 --- /dev/null +++ b/src/JasperFx.Aspire.Tests/ResolveDiscoveredTests.cs @@ -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"); + } +} diff --git a/src/JasperFx.Aspire/JasperFxAspireExtensions.cs b/src/JasperFx.Aspire/JasperFxAspireExtensions.cs index 0d3c73d..51f44d6 100644 --- a/src/JasperFx.Aspire/JasperFxAspireExtensions.cs +++ b/src/JasperFx.Aspire/JasperFxAspireExtensions.cs @@ -25,7 +25,7 @@ public static IResourceBuilder WithJasperFxCommands( 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)); } @@ -33,6 +33,23 @@ public static IResourceBuilder WithJasperFxCommands( return builder; } + private static IEnumerable 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(out var projectMetadata)) + { + var discovered = JasperFxCommandDiscovery.Discover(projectMetadata.ProjectPath, options.DiscoveryTimeout); + if (discovered != null) + { + return JasperFxVerbCatalog.ResolveDiscovered(discovered, options); + } + } + + return JasperFxVerbCatalog.Resolve(options); + } + /// /// 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 diff --git a/src/JasperFx.Aspire/JasperFxCommandDiscovery.cs b/src/JasperFx.Aspire/JasperFxCommandDiscovery.cs new file mode 100644 index 0000000..f9b542b --- /dev/null +++ b/src/JasperFx.Aspire/JasperFxCommandDiscovery.cs @@ -0,0 +1,122 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace JasperFx.Aspire; + +/// +/// Discovers a JasperFx app's actual command verbs by running help --json against the +/// already-built project at AppHost build time. Best-effort: any failure returns null so the caller +/// falls back to the curated catalog. +/// +internal static class JasperFxCommandDiscovery +{ + public static IReadOnlyList? Discover(string projectPath, TimeSpan timeout) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add("run"); + startInfo.ArgumentList.Add("--project"); + startInfo.ArgumentList.Add(projectPath); + startInfo.ArgumentList.Add("--no-build"); // use the already-built output; never trigger a build here + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add("help"); + startInfo.ArgumentList.Add("--json"); + + using var process = new Process { StartInfo = startInfo }; + var stdout = new StringBuilder(); + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) stdout.AppendLine(e.Data); + }; + + if (!process.Start()) + { + return null; + } + + process.BeginOutputReadLine(); + + if (!process.WaitForExit((int)timeout.TotalMilliseconds)) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return null; + } + + process.WaitForExit(); // let the async stdout reads flush + + if (process.ExitCode != 0) + { + return null; + } + + var names = ParseCatalog(stdout.ToString()); + return names.Count > 0 ? names : null; + } + catch + { + // Discovery is strictly best-effort — any failure falls back to the curated catalog. + return null; + } + } + + /// + /// Extract the command names from help --json stdout. The JSON array is preceded by framework + /// noise ("Searching '…' for commands", build output), so we locate the array within the output + /// rather than parsing the whole stream. + /// + internal static IReadOnlyList ParseCatalog(string stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + { + return []; + } + + var start = stdout.IndexOf('['); + var end = stdout.LastIndexOf(']'); + if (start < 0 || end <= start) + { + return []; + } + + try + { + using var document = JsonDocument.Parse(stdout.Substring(start, end - start + 1)); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + var names = new List(); + foreach (var element in document.RootElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty("name", out var nameProperty) && + nameProperty.ValueKind == JsonValueKind.String) + { + var name = nameProperty.GetString(); + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name!); + } + } + } + + return names; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/src/JasperFx.Aspire/JasperFxCommandOptions.cs b/src/JasperFx.Aspire/JasperFxCommandOptions.cs index 14b800e..ad26cd9 100644 --- a/src/JasperFx.Aspire/JasperFxCommandOptions.cs +++ b/src/JasperFx.Aspire/JasperFxCommandOptions.cs @@ -25,6 +25,18 @@ public sealed class JasperFxCommandOptions /// public bool IncludeMutatingCommands { get; set; } + /// + /// Discover the target's actual verbs at AppHost build time by running help --json against + /// the already-built project, instead of using the built-in curated list. Picks up product-specific + /// and user-defined commands automatically. If discovery fails for any reason (the project isn't + /// built, a timeout, a parse error), it silently falls back to the curated catalog. Defaults to + /// false. + /// + public bool DiscoverCommands { get; set; } + + /// How long to wait for the help --json discovery process. Defaults to 30 seconds. + public TimeSpan DiscoveryTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// /// Get (creating on first use) the presentation overrides for a verb so you can tweak its /// label, icon, or confirmation message, e.g. diff --git a/src/JasperFx.Aspire/JasperFxVerbCatalog.cs b/src/JasperFx.Aspire/JasperFxVerbCatalog.cs index eec7f2e..24b3030 100644 --- a/src/JasperFx.Aspire/JasperFxVerbCatalog.cs +++ b/src/JasperFx.Aspire/JasperFxVerbCatalog.cs @@ -91,6 +91,40 @@ public static IEnumerable Resolve(JasperFxCommandOption return selected.Where(t => !options.ExcludeVerbs.Contains(t.Verb)).ToArray(); } + /// + /// Verbs that are never rendered as dashboard buttons (the long-running service itself / help). + /// + private static readonly HashSet NonButtonVerbs = new(StringComparer.OrdinalIgnoreCase) + { + "run", "help" + }; + + /// + /// Map a set of verbs discovered from the target app (via help --json) to command templates — + /// one button per verb. Known verbs get their catalog metadata (correct mutating flag, icon); unknown + /// product-specific verbs are treated as mutating. Honors the same include/exclude/mutating gating as + /// ; run and help are never included. + /// + public static IEnumerable ResolveDiscovered( + IEnumerable verbs, JasperFxCommandOptions options) + { + var templates = verbs + .Where(v => !NonButtonVerbs.Contains(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(v => TemplateFor(v, null)); + + if (options.IncludeVerbs.Count > 0) + { + templates = templates.Where(t => options.IncludeVerbs.Contains(t.Verb)); + } + else if (!options.IncludeMutatingCommands) + { + templates = templates.Where(t => !t.Mutating); + } + + return templates.Where(t => !options.ExcludeVerbs.Contains(t.Verb)).ToArray(); + } + /// /// Find the catalog template for a verb (optionally matching fixed arguments), or synthesize a /// generic one for an unknown/product-specific verb so WithJasperFxCommand still works. diff --git a/src/JasperFx/CommandLine/Help/HelpCommand.cs b/src/JasperFx/CommandLine/Help/HelpCommand.cs index 5a52ad3..6794e58 100644 --- a/src/JasperFx/CommandLine/Help/HelpCommand.cs +++ b/src/JasperFx/CommandLine/Help/HelpCommand.cs @@ -1,4 +1,6 @@ -using Spectre.Console; +using System.Text; +using System.Text.Json; +using Spectre.Console; namespace JasperFx.CommandLine.Help; @@ -13,6 +15,14 @@ public HelpCommand() public override bool Execute(HelpInput input) { + if (input.JsonFlag) + { + // Machine-readable command catalog for tooling (e.g. the JasperFx.Aspire dashboard + // integration discovering verbs). Pure introspection — no host is built. + Console.WriteLine(ToCommandCatalogJson(input.CommandTypes)); + return true; + } + if (input.Usage != null) { input.Usage.WriteUsages(input.AppName); @@ -63,4 +73,29 @@ private void writeInvalidCommand(string commandName) AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); } + + /// + /// Render the command catalog as a JSON array of { "name", "description" } objects, sorted + /// by command name. Written with (no reflection) so it is safe under + /// trimming / AOT. + /// + public static string ToCommandCatalogJson(IEnumerable commandTypes) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartArray(); + foreach (var type in commandTypes.OrderBy(CommandFactory.CommandNameFor)) + { + writer.WriteStartObject(); + writer.WriteString("name", CommandFactory.CommandNameFor(type)); + writer.WriteString("description", CommandFactory.DescriptionFor(type)); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } } \ No newline at end of file diff --git a/src/JasperFx/CommandLine/Help/HelpInput.cs b/src/JasperFx/CommandLine/Help/HelpInput.cs index 970ab4b..004011e 100644 --- a/src/JasperFx/CommandLine/Help/HelpInput.cs +++ b/src/JasperFx/CommandLine/Help/HelpInput.cs @@ -6,6 +6,9 @@ public class HelpInput [Description("A command name")] public string Name { get; set; } = null!; + [Description("Write the command catalog as JSON to stdout (machine-readable; no host startup)")] + public bool JsonFlag { get; set; } + [IgnoreOnCommandLine] public bool InvalidCommandName { get; set; } [IgnoreOnCommandLine] public UsageGraph Usage { get; set; } = null!;