|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Configuration;
using Spectre.Console;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Commands.Sdk;
/// <summary>
/// Command for dumping ATS capabilities from Aspire integration libraries.
/// Supports multiple output formats for different use cases.
///
/// Usage:
/// aspire sdk dump [integration.csproj] # Pretty output (default)
/// aspire sdk dump --json # Machine-readable JSON
/// aspire sdk dump --ci -o capabilities.txt # Stable text for git diffing
/// </summary>
internal sealed class SdkDumpCommand : BaseCommand
{
private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
private readonly ILogger<SdkDumpCommand> _logger;
public SdkDumpCommand(
IAppHostServerProjectFactory appHostServerProjectFactory,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
ILogger<SdkDumpCommand> logger)
: base("dump", "Dump ATS capabilities from Aspire integration libraries.", features, updateNotifier, executionContext, interactionService)
{
_appHostServerProjectFactory = appHostServerProjectFactory;
_logger = logger;
// The integration project is the main input (optional - defaults to core Aspire.Hosting)
var integrationArgument = new Argument<FileInfo?>("integration")
{
Description = "Path to the integration project (.csproj). If not specified, dumps core Aspire.Hosting capabilities.",
Arity = ArgumentArity.ZeroOrOne
};
Arguments.Add(integrationArgument);
var outputOption = new Option<FileInfo?>("--output", "-o")
{
Description = "Output file. If not specified, outputs to stdout."
};
Options.Add(outputOption);
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON for machine consumption."
};
Options.Add(jsonOption);
var ciOption = new Option<bool>("--ci")
{
Description = "Output stable text format for CI/CD diffing."
};
Options.Add(ciOption);
}
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var integrationProject = parseResult.GetValue<FileInfo?>("integration");
var outputFile = parseResult.GetValue<FileInfo?>("--output");
var jsonFormat = parseResult.GetValue<bool>("--json");
var ciFormat = parseResult.GetValue<bool>("--ci");
// Validate the integration project if specified
if (integrationProject is not null)
{
if (!integrationProject.Exists)
{
InteractionService.DisplayError($"Integration project not found: {integrationProject.FullName}");
return ExitCodeConstants.FailedToFindProject;
}
if (!integrationProject.Extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase))
{
InteractionService.DisplayError($"Expected a .csproj file, got: {integrationProject.Extension}");
return ExitCodeConstants.InvalidCommand;
}
}
if (jsonFormat && ciFormat)
{
InteractionService.DisplayError("Cannot specify both --json and --ci. Choose one format.");
return ExitCodeConstants.InvalidCommand;
}
var format = jsonFormat ? OutputFormat.Json : ciFormat ? OutputFormat.Ci : OutputFormat.Pretty;
// For file output, skip the interactive spinner
if (outputFile is not null)
{
return await DumpCapabilitiesAsync(integrationProject, outputFile, format, cancellationToken);
}
return await InteractionService.ShowStatusAsync(
":magnifying_glass_tilted_right: Scanning capabilities...",
async () => await DumpCapabilitiesAsync(integrationProject, outputFile, format, cancellationToken));
}
private async Task<int> DumpCapabilitiesAsync(
FileInfo? integrationProject,
FileInfo? outputFile,
OutputFormat format,
CancellationToken cancellationToken)
{
// Use a temporary directory for the AppHost server
var tempDir = Path.Combine(Path.GetTempPath(), "aspire-sdk-dump", Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(tempDir);
try
{
var appHostServerProject = _appHostServerProjectFactory.Create(tempDir);
var socketPath = appHostServerProject.GetSocketPath();
// Build packages list - empty since we only need core capabilities + optional integration
var packages = new List<(string Name, string Version)>();
_logger.LogDebug("Building AppHost server for capability scanning");
// Create project files with the integration project reference if specified
var additionalProjectRefs = integrationProject is not null
? new[] { integrationProject.FullName }
: null;
await appHostServerProject.CreateProjectFilesAsync(
AppHostServerProject.DefaultSdkVersion,
packages,
cancellationToken,
additionalProjectReferences: additionalProjectRefs);
var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken);
if (!buildSuccess)
{
InteractionService.DisplayError("Failed to build capability scanner.");
foreach (var (_, line) in buildOutput.GetLines())
{
InteractionService.DisplayMessage("wrench", line.EscapeMarkup());
}
return ExitCodeConstants.FailedToBuildArtifacts;
}
// Start the server
var currentPid = Environment.ProcessId;
var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary<string, string>());
try
{
// Connect and get capabilities
await using var rpcClient = await AppHostRpcClient.ConnectAsync(socketPath, cancellationToken);
_logger.LogDebug("Fetching capabilities via RPC");
var capabilities = await rpcClient.GetCapabilitiesAsync(cancellationToken);
// Output Info-level diagnostics to stderr via logger (shown with -d flag)
var infoDiagnostics = capabilities.Diagnostics.Where(d => d.Severity == "Info").ToList();
foreach (var diag in infoDiagnostics)
{
var location = string.IsNullOrEmpty(diag.Location) ? "" : $" [{diag.Location}]";
_logger.LogDebug("{Message}{Location}", diag.Message, location);
}
// Remove Info diagnostics from output (they go to stderr only)
capabilities.Diagnostics.RemoveAll(d => d.Severity == "Info");
// Format the output
var output = format switch
{
OutputFormat.Json => FormatJson(capabilities),
OutputFormat.Ci => FormatCi(capabilities),
_ => FormatPretty(capabilities)
};
// Write output
if (outputFile is not null)
{
var outputDir = outputFile.Directory;
if (outputDir is not null && !outputDir.Exists)
{
outputDir.Create();
}
await File.WriteAllTextAsync(outputFile.FullName, output, cancellationToken);
InteractionService.DisplaySuccess($"Capabilities written to {outputFile.FullName}");
}
else
{
// Output to stdout
Console.WriteLine(output);
}
// Return error code if there are errors in diagnostics
var hasErrors = capabilities.Diagnostics.Exists(d => d.Severity == "Error");
return hasErrors ? ExitCodeConstants.InvalidCommand : ExitCodeConstants.Success;
}
finally
{
// Stop the server - just try to kill, catch if already exited
try
{
serverProcess.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Process already exited - this is fine
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process");
}
}
}
finally
{
// Clean up temp directory
try
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to clean up temp directory {TempDir}", tempDir);
}
}
}
#region Output Formatters
private static string FormatJson(CapabilitiesInfo capabilities)
{
return JsonSerializer.Serialize(capabilities, CapabilitiesJsonContext.Default.CapabilitiesInfo);
}
private static string FormatCi(CapabilitiesInfo capabilities)
{
var sb = new StringBuilder();
// Header (no timestamp for stable diffs)
sb.AppendLine("# Aspire Type System Capabilities");
sb.AppendLine("# Generated by: aspire sdk dump --ci");
sb.AppendLine();
// Diagnostics
if (capabilities.Diagnostics.Count > 0)
{
sb.AppendLine("# Diagnostics");
foreach (var d in capabilities.Diagnostics.OrderBy(d => d.Severity).ThenBy(d => d.Location))
{
var loc = string.IsNullOrEmpty(d.Location) ? "" : string.Format(CultureInfo.InvariantCulture, " [{0}]", d.Location);
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}: {1}{2}", d.Severity.ToLowerInvariant(), d.Message, loc));
}
sb.AppendLine();
}
// Handle Types
sb.AppendLine("# Handle Types");
foreach (var t in capabilities.HandleTypes.OrderBy(t => t.AtsTypeId))
{
var flags = new List<string>();
if (t.IsInterface)
{
flags.Add("interface");
}
if (t.ExposeProperties)
{
flags.Add("ExposeProperties");
}
if (t.ExposeMethods)
{
flags.Add("ExposeMethods");
}
var flagStr = flags.Count > 0 ? string.Format(CultureInfo.InvariantCulture, " [{0}]", string.Join(", ", flags)) : "";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}{1}", t.AtsTypeId, flagStr));
}
sb.AppendLine();
// DTO Types
if (capabilities.DtoTypes.Count > 0)
{
sb.AppendLine("# DTO Types");
foreach (var t in capabilities.DtoTypes.OrderBy(t => t.TypeId))
{
sb.AppendLine(t.TypeId);
foreach (var p in t.Properties.OrderBy(p => p.Name))
{
var optional = p.IsOptional ? "?" : "";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}{1}: {2}", p.Name, optional, p.Type?.TypeId ?? "unknown"));
}
}
sb.AppendLine();
}
// Enum Types
if (capabilities.EnumTypes.Count > 0)
{
sb.AppendLine("# Enum Types");
foreach (var t in capabilities.EnumTypes.OrderBy(t => t.TypeId))
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0} = {1}", t.TypeId, string.Join(" | ", t.Values)));
}
sb.AppendLine();
}
// Capabilities
sb.AppendLine("# Capabilities");
foreach (var c in capabilities.Capabilities.OrderBy(c => c.CapabilityId))
{
var paramStr = string.Join(", ", c.Parameters.Select(p =>
{
var optional = p.IsOptional ? "?" : "";
return string.Format(CultureInfo.InvariantCulture, "{0}{1}: {2}", p.Name, optional, p.Type?.TypeId ?? "unknown");
}));
var returnStr = c.ReturnType?.TypeId ?? "void";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}({1}) -> {2}", c.CapabilityId, paramStr, returnStr));
}
return sb.ToString();
}
private static string FormatPretty(CapabilitiesInfo capabilities)
{
var sb = new StringBuilder();
// Header
sb.AppendLine("================================================================================");
sb.AppendLine(" Aspire Type System Capabilities ");
sb.AppendLine("================================================================================");
sb.AppendLine();
// Summary
var errorCount = capabilities.Diagnostics.Count(d => d.Severity == "Error");
var warningCount = capabilities.Diagnostics.Count(d => d.Severity == "Warning");
sb.AppendLine("Summary");
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Handle Types: {0}", capabilities.HandleTypes.Count));
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " DTO Types: {0}", capabilities.DtoTypes.Count));
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Enum Types: {0}", capabilities.EnumTypes.Count));
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Capabilities: {0}", capabilities.Capabilities.Count));
if (errorCount > 0 || warningCount > 0)
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Diagnostics: {0} errors, {1} warnings", errorCount, warningCount));
}
sb.AppendLine();
// Diagnostics
if (capabilities.Diagnostics.Count > 0)
{
sb.AppendLine("Diagnostics");
sb.AppendLine("--------------------------------------------------------------------------------");
foreach (var d in capabilities.Diagnostics.OrderBy(d => d.Severity).ThenBy(d => d.Location))
{
var icon = d.Severity == "Error" ? "[ERROR]" : "[WARN]";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} {1}", icon, d.Message));
if (!string.IsNullOrEmpty(d.Location))
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " -> {0}", d.Location));
}
}
sb.AppendLine();
}
// Handle Types
sb.AppendLine("Handle Types (passed by reference)");
sb.AppendLine("--------------------------------------------------------------------------------");
foreach (var t in capabilities.HandleTypes.OrderBy(t => t.AtsTypeId))
{
var flags = new List<string>();
if (t.IsInterface)
{
flags.Add("interface");
}
if (t.ExposeProperties)
{
flags.Add("properties");
}
if (t.ExposeMethods)
{
flags.Add("methods");
}
var flagStr = flags.Count > 0 ? string.Format(CultureInfo.InvariantCulture, " ({0})", string.Join(", ", flags)) : "";
// Extract short name from AtsTypeId
var shortName = t.AtsTypeId.Contains('/')
? t.AtsTypeId.Split('/')[1]
: t.AtsTypeId;
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}{1}", shortName, flagStr));
}
sb.AppendLine();
// DTO Types
if (capabilities.DtoTypes.Count > 0)
{
sb.AppendLine("DTO Types (serialized as JSON)");
sb.AppendLine("--------------------------------------------------------------------------------");
foreach (var t in capabilities.DtoTypes.OrderBy(t => t.Name))
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}", t.Name));
foreach (var p in t.Properties.OrderBy(p => p.Name))
{
var optional = p.IsOptional ? "?" : "";
var typeId = p.Type?.TypeId ?? "unknown";
// Simplify type display
var simpleType = SimplifyTypeName(typeId);
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " - {0}{1}: {2}", p.Name, optional, simpleType));
}
}
sb.AppendLine();
}
// Enum Types
if (capabilities.EnumTypes.Count > 0)
{
sb.AppendLine("Enum Types");
sb.AppendLine("--------------------------------------------------------------------------------");
foreach (var t in capabilities.EnumTypes.OrderBy(t => t.Name))
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}", t.Name));
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}", string.Join(" | ", t.Values)));
}
sb.AppendLine();
}
// Capabilities (grouped by category if available)
sb.AppendLine("Capabilities");
sb.AppendLine("--------------------------------------------------------------------------------");
var capsByTarget = capabilities.Capabilities
.GroupBy(c => c.OwningTypeName ?? "Extension Methods")
.OrderBy(g => g.Key is null or "Extension Methods") // Sort nulls/extension methods last
.ThenBy(g => g.Key);
foreach (var group in capsByTarget)
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " [{0}]", group.Key));
foreach (var c in group.OrderBy(c => c.MethodName))
{
var paramStr = string.Join(", ", c.Parameters.Select(p =>
{
var optional = p.IsOptional ? "?" : "";
var simpleType = SimplifyTypeName(p.Type?.TypeId ?? "unknown");
return string.Format(CultureInfo.InvariantCulture, "{0}{1}: {2}", p.Name, optional, simpleType);
}));
var returnType = SimplifyTypeName(c.ReturnType?.TypeId ?? "void");
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}({1}) -> {2}", c.MethodName, paramStr, returnType));
if (!string.IsNullOrEmpty(c.Description))
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0}", c.Description));
}
}
}
return sb.ToString();
}
private static string SimplifyTypeName(string typeId)
{
// Remove assembly prefix
if (typeId.Contains('/'))
{
typeId = typeId.Split('/')[1];
}
// Remove namespace
var lastDot = typeId.LastIndexOf('.');
if (lastDot > 0)
{
typeId = typeId[(lastDot + 1)..];
}
return typeId;
}
#endregion
private enum OutputFormat
{
Pretty,
Json,
Ci
}
}
#region Response DTOs (matching server response)
internal sealed class CapabilitiesInfo
{
public List<CapabilityInfo> Capabilities { get; set; } = [];
public List<HandleTypeInfo> HandleTypes { get; set; } = [];
public List<DtoTypeInfo> DtoTypes { get; set; } = [];
public List<EnumTypeInfo> EnumTypes { get; set; } = [];
public List<DiagnosticInfo> Diagnostics { get; set; } = [];
}
internal sealed class CapabilityInfo
{
public string CapabilityId { get; set; } = "";
public string MethodName { get; set; } = "";
public string? OwningTypeName { get; set; }
public string QualifiedMethodName { get; set; } = "";
public string? Description { get; set; }
public string CapabilityKind { get; set; } = "";
public string? TargetTypeId { get; set; }
public string? TargetParameterName { get; set; }
public bool ReturnsBuilder { get; set; }
public List<ParameterInfo> Parameters { get; set; } = [];
public TypeRefInfo? ReturnType { get; set; }
public TypeRefInfo? TargetType { get; set; }
public List<TypeRefInfo> ExpandedTargetTypes { get; set; } = [];
}
internal sealed class ParameterInfo
{
public string Name { get; set; } = "";
public TypeRefInfo? Type { get; set; }
public bool IsOptional { get; set; }
public bool IsNullable { get; set; }
public bool IsCallback { get; set; }
public List<CallbackParameterInfo>? CallbackParameters { get; set; }
public TypeRefInfo? CallbackReturnType { get; set; }
public string? DefaultValue { get; set; }
}
internal sealed class CallbackParameterInfo
{
public string Name { get; set; } = "";
public TypeRefInfo? Type { get; set; }
}
internal sealed class TypeRefInfo
{
public string TypeId { get; set; } = "";
public string Category { get; set; } = "";
public bool IsInterface { get; set; }
public bool IsReadOnly { get; set; }
public TypeRefInfo? ElementType { get; set; }
public TypeRefInfo? KeyType { get; set; }
public TypeRefInfo? ValueType { get; set; }
public List<TypeRefInfo>? UnionTypes { get; set; }
}
internal sealed class HandleTypeInfo
{
public string AtsTypeId { get; set; } = "";
public bool IsInterface { get; set; }
public bool ExposeProperties { get; set; }
public bool ExposeMethods { get; set; }
public List<TypeRefInfo> ImplementedInterfaces { get; set; } = [];
public List<TypeRefInfo> BaseTypeHierarchy { get; set; } = [];
}
internal sealed class DtoTypeInfo
{
public string TypeId { get; set; } = "";
public string Name { get; set; } = "";
public List<DtoPropertyInfo> Properties { get; set; } = [];
}
internal sealed class DtoPropertyInfo
{
public string Name { get; set; } = "";
public TypeRefInfo? Type { get; set; }
public bool IsOptional { get; set; }
}
internal sealed class EnumTypeInfo
{
public string TypeId { get; set; } = "";
public string Name { get; set; } = "";
public List<string> Values { get; set; } = [];
}
internal sealed class DiagnosticInfo
{
public string Severity { get; set; } = "";
public string Message { get; set; } = "";
public string? Location { get; set; }
}
#endregion
#region JSON Source Generation Context
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(CapabilitiesInfo))]
[JsonSerializable(typeof(CapabilityInfo))]
[JsonSerializable(typeof(ParameterInfo))]
[JsonSerializable(typeof(CallbackParameterInfo))]
[JsonSerializable(typeof(TypeRefInfo))]
[JsonSerializable(typeof(HandleTypeInfo))]
[JsonSerializable(typeof(DtoTypeInfo))]
[JsonSerializable(typeof(DtoPropertyInfo))]
[JsonSerializable(typeof(EnumTypeInfo))]
[JsonSerializable(typeof(DiagnosticInfo))]
internal partial class CapabilitiesJsonContext : JsonSerializerContext
{
}
#endregion
|