File: Commands\Sdk\SdkGenerateCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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 Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands.Sdk;
 
/// <summary>
/// Command for generating SDKs from Aspire integration libraries.
/// Takes an integration class library (with [AspireExport] attributes) and generates
/// typed SDK code for other languages.
/// 
/// Usage: aspire sdk generate ./MyIntegration.csproj -l typescript -o ./output
/// </summary>
internal sealed class SdkGenerateCommand : BaseCommand
{
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
    private readonly IAppHostServerSessionFactory _serverSessionFactory;
    private readonly ILogger<SdkGenerateCommand> _logger;
 
    private static readonly Argument<FileInfo> s_integrationArgument = new("integration")
    {
        Description = "Path to the integration project (.csproj) to generate SDK from"
    };
    private static readonly Option<string> s_languageOption = new("--language", "-l")
    {
        Description = "Target language for SDK generation (e.g., typescript)",
        Required = true
    };
    private static readonly Option<DirectoryInfo> s_outputOption = new("--output", "-o")
    {
        Description = "Output directory for generated SDK files",
        Required = true
    };
 
    public SdkGenerateCommand(
        ILanguageDiscovery languageDiscovery,
        IAppHostServerProjectFactory appHostServerProjectFactory,
        IAppHostServerSessionFactory serverSessionFactory,
        ILogger<SdkGenerateCommand> logger,
        CommonCommandServices services)
        : base("generate", "Generate typed SDKs from an Aspire integration library for use in other languages.", services)
    {
        _languageDiscovery = languageDiscovery;
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _serverSessionFactory = serverSessionFactory;
        _logger = logger;
 
        Arguments.Add(s_integrationArgument);
        Options.Add(s_languageOption);
        Options.Add(s_outputOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var integrationProject = parseResult.GetValue(s_integrationArgument)!;
        var language = parseResult.GetValue(s_languageOption)!;
        var outputDir = parseResult.GetValue(s_outputOption)!;
 
        // Validate the integration project exists
        if (!integrationProject.Exists)
        {
            return CommandResult.Failure(CliExitCodes.FailedToFindProject, $"Integration project not found: {integrationProject.FullName}");
        }
 
        if (!integrationProject.Extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Expected a .csproj file, got: {integrationProject.Extension}");
        }
 
        // Resolve the language info
        var languageInfo = await GetLanguageInfoAsync(language, cancellationToken);
        if (languageInfo is null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Unsupported language: {language}");
        }
 
        // Create output directory if it doesn't exist
        if (!outputDir.Exists)
        {
            outputDir.Create();
        }
 
        return CommandResult.FromExitCode(await InteractionService.ShowStatusAsync(
            $"Generating {languageInfo.DisplayName} SDK from {integrationProject.Name}...",
            async () => await GenerateSdkAsync(integrationProject, languageInfo, outputDir, cancellationToken),
            emoji: KnownEmojis.Hammer));
    }
 
    private async Task<LanguageInfo?> GetLanguageInfoAsync(string language, CancellationToken cancellationToken)
    {
        var languages = await _languageDiscovery.GetAvailableLanguagesAsync(cancellationToken);
 
        // Match by language ID or code generator name
        return languages.FirstOrDefault(l =>
            l.LanguageId.Value.StartsWith(language, StringComparison.OrdinalIgnoreCase) ||
            l.CodeGenerator.Equals(language, StringComparison.OrdinalIgnoreCase));
    }
 
    private async Task<int> GenerateSdkAsync(
        FileInfo integrationProject,
        LanguageInfo languageInfo,
        DirectoryInfo outputDir,
        CancellationToken cancellationToken)
    {
        var integrationAssemblyName = IntegrationAssemblyNameResolver.Resolve(integrationProject);
 
        // Use a temporary directory for the AppHost server
        var tempDir = Path.Combine(Path.GetTempPath(), "aspire-sdk-gen", Guid.NewGuid().ToString("N")[..8]);
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);
 
            // Get code generation package for the target language
            var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken);
 
            // Build integrations list — the integration project brings Aspire.Hosting transitively;
            // we only need to add the codegen package and the project reference itself.
            var integrations = new List<IntegrationReference>();
            if (codeGenPackage is not null)
            {
                integrations.Add(IntegrationReference.FromPackage(codeGenPackage, ExecutionContext.IdentityVersion));
            }
 
            // Add the integration project as a project reference
            integrations.Add(IntegrationReference.FromProject(
                integrationAssemblyName,
                integrationProject.FullName));
 
            _logger.LogDebug("Building AppHost server for SDK generation");
 
            var prepareResult = await appHostServerProject.PrepareAsync(
                ExecutionContext.IdentityVersion,
                integrations,
                cancellationToken: cancellationToken);
 
            if (!prepareResult.Success)
            {
                InteractionService.DisplayError("Failed to build SDK generation server.");
                if (prepareResult.Output is not null)
                {
                    foreach (var (_, line) in prepareResult.Output.GetLines())
                    {
                        InteractionService.DisplayMessage(KnownEmojis.Wrench, line);
                    }
                }
                return CliExitCodes.FailedToBuildArtifacts;
            }
 
            await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);
            // Short-lived RPC session: StartAsync() spawns the server. We never observe the
            // exit-code task (WaitForExitAsync) because disposal flows the exit code through the
            // activity scope and the only failure mode we care about surfaces via the RPC call below.
            await serverSession.StartAsync();
 
            // Connect and generate code
            var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
 
            _logger.LogDebug("Generating {Language} SDK via RPC", languageInfo.CodeGenerator);
            var generatedFiles = await rpcClient.GenerateCodeForAssemblyAsync(languageInfo.CodeGenerator, integrationAssemblyName, cancellationToken);
 
            // Write generated files
            var outputDirFullPath = Path.GetFullPath(outputDir.FullName);
            foreach (var (fileName, content) in generatedFiles)
            {
                var filePath = Path.GetFullPath(Path.Combine(outputDir.FullName, fileName));
 
                // Validate path is within output directory (prevent path traversal)
                if (!filePath.StartsWith(outputDirFullPath, StringComparison.OrdinalIgnoreCase))
                {
                    _logger.LogWarning("Skipping file with invalid path: {FileName}", fileName);
                    continue;
                }
 
                var fileDirectory = Path.GetDirectoryName(filePath);
                if (!string.IsNullOrEmpty(fileDirectory))
                {
                    Directory.CreateDirectory(fileDirectory);
                }
                await File.WriteAllTextAsync(filePath, content, cancellationToken);
                _logger.LogDebug("Wrote {FileName}", fileName);
            }
 
            InteractionService.DisplaySuccess($"Generated {generatedFiles.Count} files in {outputDir.FullName}");
 
            return CliExitCodes.Success;
        }
        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);
            }
        }
    }
}