File: Scaffolding\ScaffoldingService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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 Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Scaffolding;
 
/// <summary>
/// Service for scaffolding new AppHost projects.
/// Handles both C# (via templates) and guest languages (via RPC).
/// </summary>
internal sealed class ScaffoldingService : IScaffoldingService
{
    private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly IInteractionService _interactionService;
    private readonly IPackagingService _packagingService;
    private readonly IConfigurationService _configurationService;
    private readonly ILogger<ScaffoldingService> _logger;
 
    public ScaffoldingService(
        IAppHostServerProjectFactory appHostServerProjectFactory,
        ILanguageDiscovery languageDiscovery,
        IInteractionService interactionService,
        IPackagingService packagingService,
        IConfigurationService configurationService,
        ILogger<ScaffoldingService> logger)
    {
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _languageDiscovery = languageDiscovery;
        _interactionService = interactionService;
        _packagingService = packagingService;
        _configurationService = configurationService;
        _logger = logger;
    }
 
    /// <inheritdoc />
    public async Task ScaffoldAsync(ScaffoldContext context, CancellationToken cancellationToken)
    {
        if (context.Language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase))
        {
            throw new NotSupportedException("C# projects should be created using the template system via NewCommand.");
        }
 
        await ScaffoldGuestLanguageAsync(context, cancellationToken);
    }
 
    private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, CancellationToken cancellationToken)
    {
        var directory = context.TargetDirectory;
        var language = context.Language;
 
        // Step 1: Resolve SDK version from channel (if configured) or use default
        var sdkVersion = await ResolveSdkVersionAsync(cancellationToken);
        
        // Load or create config with resolved SDK version
        var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, sdkVersion);
 
        // Include the code generation package for scaffolding and code gen
        var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken);
        var packages = config.GetAllPackages().ToList();
        if (codeGenPackage is not null)
        {
            packages.Add((codeGenPackage, config.SdkVersion!));
        }
 
        var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
        var socketPath = appHostServerProject.GetSocketPath();
 
        var (buildSuccess, buildOutput, channelName) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken);
        if (!buildSuccess)
        {
            _interactionService.DisplayLines(buildOutput.GetLines());
            _interactionService.DisplayError("Failed to build AppHost server.");
            return;
        }
 
        // Step 2: Start the server temporarily for scaffolding and code generation
        var currentPid = Environment.ProcessId;
        var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary<string, string>());
 
        try
        {
            // Step 3: Connect to server and get scaffold templates via RPC
            await using var rpcClient = await AppHostRpcClient.ConnectAsync(socketPath, cancellationToken);
 
            var scaffoldFiles = await rpcClient.ScaffoldAppHostAsync(
                language.LanguageId,
                directory.FullName,
                context.ProjectName,
                cancellationToken);
 
            // Step 4: Write scaffold files to disk
            foreach (var (fileName, content) in scaffoldFiles)
            {
                var filePath = Path.Combine(directory.FullName, fileName);
                var fileDirectory = Path.GetDirectoryName(filePath);
                if (!string.IsNullOrEmpty(fileDirectory))
                {
                    Directory.CreateDirectory(fileDirectory);
                }
                await File.WriteAllTextAsync(filePath, content, cancellationToken);
            }
 
            _logger.LogDebug("Wrote {Count} scaffold files", scaffoldFiles.Count);
 
            // Step 5: Install dependencies using GuestRuntime
            var installResult = await InstallDependenciesAsync(directory, language, rpcClient, cancellationToken);
            if (installResult != 0)
            {
                return;
            }
 
            // Step 6: Generate SDK code via RPC
            await GenerateCodeViaRpcAsync(
                directory.FullName,
                rpcClient,
                language,
                cancellationToken);
 
            // Save channel and language to settings.json
            if (channelName is not null)
            {
                config.Channel = channelName;
            }
            config.Language = language.LanguageId;
            config.Save(directory.FullName);
        }
        finally
        {
            // Step 7: Stop the server
            if (!serverProcess.HasExited)
            {
                try
                {
                    serverProcess.Kill(entireProcessTree: true);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Error killing AppHost server process after scaffolding");
                }
            }
        }
    }
 
    private static async Task<(bool Success, OutputCollector Output, string? ChannelName)> BuildAppHostServerAsync(
        AppHostServerProject appHostServerProject,
        string sdkVersion,
        List<(string Name, string Version)> packages,
        CancellationToken cancellationToken)
    {
        var outputCollector = new OutputCollector();
 
        var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(sdkVersion, packages, cancellationToken);
        var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken);
        if (!buildSuccess)
        {
            foreach (var (_, line) in buildOutput.GetLines())
            {
                outputCollector.AppendOutput(line);
            }
        }
 
        return (buildSuccess, outputCollector, channelName);
    }
 
    private async Task<int> InstallDependenciesAsync(
        DirectoryInfo directory,
        LanguageInfo language,
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(language.LanguageId.Value, cancellationToken);
        var runtime = new GuestRuntime(runtimeSpec, _logger);
 
        var result = await runtime.InstallDependenciesAsync(directory, cancellationToken);
        if (result != 0)
        {
            _interactionService.DisplayError($"Failed to install {language.DisplayName} dependencies.");
        }
 
        return result;
    }
 
    private const string GeneratedFolderName = ".modules";
 
    private async Task GenerateCodeViaRpcAsync(
        string directoryPath,
        IAppHostRpcClient rpcClient,
        LanguageInfo language,
        CancellationToken cancellationToken)
    {
        var generatedFiles = await rpcClient.GenerateCodeAsync(language.CodeGenerator, cancellationToken);
 
        // Write generated files to the output directory
        var outputPath = Path.Combine(directoryPath, GeneratedFolderName);
        Directory.CreateDirectory(outputPath);
 
        foreach (var (fileName, content) in generatedFiles)
        {
            var filePath = Path.Combine(outputPath, fileName);
            var fileDirectory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(fileDirectory))
            {
                Directory.CreateDirectory(fileDirectory);
            }
            await File.WriteAllTextAsync(filePath, content, cancellationToken);
        }
 
        _logger.LogDebug("Generated {Count} code files in {Path}", generatedFiles.Count, outputPath);
    }
 
    /// <summary>
    /// Resolves the SDK version to use for scaffolding.
    /// If a channel is configured globally, queries that channel for available versions.
    /// Otherwise, falls back to the default SDK version.
    /// </summary>
    private async Task<string> ResolveSdkVersionAsync(CancellationToken cancellationToken)
    {
        // Check for global channel setting
        var channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
        if (string.IsNullOrEmpty(channelName))
        {
            return AppHostServerProject.DefaultSdkVersion;
        }
 
        // Find the matching channel
        var allChannels = await _packagingService.GetChannelsAsync(cancellationToken);
        var channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
        if (channel is null)
        {
            _logger.LogWarning("Configured channel '{Channel}' not found, using default SDK version", channelName);
            return AppHostServerProject.DefaultSdkVersion;
        }
 
        // Get template packages from the channel to determine SDK version
        var templatePackages = await channel.GetTemplatePackagesAsync(new DirectoryInfo(Environment.CurrentDirectory), cancellationToken);
        var latestPackage = templatePackages
            .OrderByDescending(p => SemVersion.Parse(p.Version), SemVersion.PrecedenceComparer)
            .FirstOrDefault();
 
        if (latestPackage is null)
        {
            _logger.LogWarning("No packages found in channel '{Channel}', using default SDK version", channelName);
            return AppHostServerProject.DefaultSdkVersion;
        }
 
        _logger.LogDebug("Resolved SDK version {Version} from channel {Channel}", latestPackage.Version, channelName);
        return latestPackage.Version;
    }
}