File: Commands\InitCommand.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 System.Globalization;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating;
using Aspire.Cli.Utils;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
using Semver;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
internal sealed class InitCommand : BaseCommand, IPackageMetaPrefetchingCommand
{
    private readonly IDotNetCliRunner _runner;
    private readonly ICertificateService _certificateService;
    private readonly INewCommandPrompter _prompter;
    private readonly ITemplateFactory _templateFactory;
    private readonly IPackagingService _packagingService;
    private readonly ISolutionLocator _solutionLocator;
    private readonly AspireCliTelemetry _telemetry;
    private readonly IDotNetSdkInstaller _sdkInstaller;
    private readonly IFeatures _features;
    private readonly ICliUpdateNotifier _updateNotifier;
    private readonly CliExecutionContext _executionContext;
 
    /// <summary>
    /// InitCommand prefetches template package metadata.
    /// </summary>
    public bool PrefetchesTemplatePackageMetadata => true;
    
    /// <summary>
    /// InitCommand prefetches CLI package metadata for update notifications.
    /// </summary>
    public bool PrefetchesCliPackageMetadata => true;
 
    public InitCommand(
        IDotNetCliRunner runner,
        ICertificateService certificateService,
        INewCommandPrompter prompter,
        ITemplateFactory templateFactory,
        IPackagingService packagingService,
        ISolutionLocator solutionLocator,
        AspireCliTelemetry telemetry,
        IDotNetSdkInstaller sdkInstaller,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        IInteractionService interactionService)
        : base("init", InitCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
    {
        ArgumentNullException.ThrowIfNull(runner);
        ArgumentNullException.ThrowIfNull(certificateService);
        ArgumentNullException.ThrowIfNull(prompter);
        ArgumentNullException.ThrowIfNull(templateFactory);
        ArgumentNullException.ThrowIfNull(packagingService);
        ArgumentNullException.ThrowIfNull(solutionLocator);
        ArgumentNullException.ThrowIfNull(telemetry);
        ArgumentNullException.ThrowIfNull(sdkInstaller);
 
        _runner = runner;
        _certificateService = certificateService;
        _prompter = prompter;
        _templateFactory = templateFactory;
        _packagingService = packagingService;
        _solutionLocator = solutionLocator;
        _telemetry = telemetry;
        _sdkInstaller = sdkInstaller;
        _features = features;
        _updateNotifier = updateNotifier;
        _executionContext = executionContext;
 
        var sourceOption = new Option<string?>("--source", "-s");
        sourceOption.Description = NewCommandStrings.SourceArgumentDescription;
        sourceOption.Recursive = true;
        Options.Add(sourceOption);
 
        var templateVersionOption = new Option<string?>("--version", "-v");
        templateVersionOption.Description = NewCommandStrings.VersionArgumentDescription;
        templateVersionOption.Recursive = true;
        Options.Add(templateVersionOption);
    }
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        // Check if the .NET SDK is available
        if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, cancellationToken))
        {
            return ExitCodeConstants.SdkNotInstalled;
        }
 
        using var activity = _telemetry.ActivitySource.StartActivity(this.Name);
 
        // Create the init context to build up a model of the operation
        var initContext = new InitContext();
 
        // Use SolutionLocator to find solution files, walking up the directory tree
        initContext.SelectedSolutionFile = await _solutionLocator.FindSolutionFileAsync(_executionContext.WorkingDirectory, cancellationToken);
 
        if (initContext.SelectedSolutionFile is not null)
        {
            InteractionService.DisplayEmptyLine();
            InteractionService.DisplayMessage("information", string.Format(CultureInfo.CurrentCulture, InitCommandStrings.SolutionDetected, initContext.SelectedSolutionFile.Name));
            InteractionService.DisplayEmptyLine();
            return await InitializeExistingSolutionAsync(initContext, parseResult, cancellationToken);
        }
        else
        {
            InteractionService.DisplayEmptyLine();
            InteractionService.DisplayMessage("information", InitCommandStrings.NoSolutionFoundCreatingSingleFileAppHost);
            InteractionService.DisplayEmptyLine();
            return await CreateEmptyAppHostAsync(parseResult, cancellationToken);
        }
    }
 
    private async Task<int> InitializeExistingSolutionAsync(InitContext initContext, ParseResult parseResult, CancellationToken cancellationToken)
    {
        var solutionFile = initContext.SelectedSolutionFile!;
 
        var (getSolutionExitCode, solutionProjects) = await InteractionService.ShowStatusAsync("Reading solution...", async () =>
        {
            return await _runner.GetSolutionProjectsAsync(
                solutionFile,
                new DotNetCliRunnerInvocationOptions(),
                cancellationToken);
        });
 
        if (getSolutionExitCode != 0)
        {
            InteractionService.DisplayError("Failed to get projects from solution.");
            return getSolutionExitCode;
        }
 
        initContext.SolutionProjects = solutionProjects;
 
        _ = await InteractionService.ShowStatusAsync("Evaluating existing projects...", async () =>
        {
            await EvaluateSolutionProjectsAsync(initContext, cancellationToken);
 
            // HACK: Need to fix up InteractionService to support Task return from status operations.
            return 0;
        });
 
        if (initContext.AlreadyHasAppHost)
        {
            InteractionService.DisplayMessage("check_mark", InitCommandStrings.SolutionAlreadyInitialized);
            return ExitCodeConstants.Success;
        }
 
        // If there are executable projects, prompt user to select which ones to add to appHost
        if (initContext.ExecutableProjects.Count > 0)
        {
            var addExecutableProjectsMessage = """
                                               # Add existing projects to AppHost?
 
                                               The following projects were found in the solution that can be
                                               hosted in Aspire. Select the ones that you would like to be
                                               added to the AppHost project. You can add or remove them
                                               later as needed.
                                               """;
 
            InteractionService.DisplayEmptyLine();
            InteractionService.DisplayMarkdown(addExecutableProjectsMessage);
            InteractionService.DisplayEmptyLine();
 
            var selectedProjects = await InteractionService.PromptForSelectionsAsync(
                "Select projects to add to the AppHost:",
                initContext.ExecutableProjects,
                project => Path.GetFileNameWithoutExtension(project.Name),
                cancellationToken);
 
            initContext.ExecutableProjectsToAddToAppHost = selectedProjects;
 
            // If projects were selected, prompt for which should have ServiceDefaults added
            if (initContext.ExecutableProjectsToAddToAppHost.Count > 0)
            {
                InteractionService.DisplayEmptyLine();
                InteractionService.DisplayMessage("information", "The following projects will be added to the AppHost:");
                InteractionService.DisplayEmptyLine();
 
                foreach (var project in initContext.ExecutableProjectsToAddToAppHost)
                {
                    InteractionService.DisplayMessage("check_box_with_check", project.Name);
                }
 
                var addServiceDefaultsMessage = """
                                # Add ServiceDefaults reference to selected projects?
 
                                Do you want to add a reference to the ServiceDefaults project to
                                the executable projects that will be added to the AppHost? The 
                                ServiceDefaults project contains helper code to make it easier
                                for you to configure telemetry and service discovery in Aspire.
                                """;
 
                InteractionService.DisplayEmptyLine();
                InteractionService.DisplayMarkdown(addServiceDefaultsMessage);
                InteractionService.DisplayEmptyLine();
 
                var serviceDefaultsActions = new Dictionary<string, string>
                {
                    { "all", "Add to all previously added projects" },
                    { "choose", "Let me choose" },
                    { "none", "Do not add to any projects" }
                };
 
                var selection = await InteractionService.PromptForSelectionAsync(
                    "Add ServiceDefaults reference?",
                    serviceDefaultsActions,
                    (action) => action.Value,
                    cancellationToken
                );
 
                switch (selection.Key)
                {
                    case "all":
                        initContext.ProjectsToAddServiceDefaultsTo = initContext.ExecutableProjectsToAddToAppHost;
                        break;
                    case "choose":
                        initContext.ProjectsToAddServiceDefaultsTo = await InteractionService.PromptForSelectionsAsync(
                            "Select projects to add ServiceDefaults reference to:",
                            initContext.ExecutableProjectsToAddToAppHost,
                            project => Path.GetFileNameWithoutExtension(project.Name),
                            cancellationToken);
                        break;
                    case "none":
                        initContext.ProjectsToAddServiceDefaultsTo = Array.Empty<FileInfo>();
                        break;
                }
            }
        }
     
        // Get template version/channel selection using the same logic as NewCommand
        var selectedTemplateDetails = await GetProjectTemplatesVersionAsync(parseResult, cancellationToken);
        
        // Create a temporary directory for the template output
        var tempProjectDir = Path.Combine(Path.GetTempPath(), $"aspire-init-{Guid.NewGuid()}");
        Directory.CreateDirectory(tempProjectDir);
        
        try
        {
            // Create temporary NuGet config if using explicit channel
            using var temporaryConfig = selectedTemplateDetails.Channel.Type == PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(selectedTemplateDetails.Channel.Mappings!) : null;
            
            // Install templates first if needed
            var templateInstallResult = await InteractionService.ShowStatusAsync(
                "Getting templates...",
                async () =>
                {
                    var options = new DotNetCliRunnerInvocationOptions();
                    return await _runner.InstallTemplateAsync(
                        packageName: "Aspire.ProjectTemplates",
                        version: selectedTemplateDetails.Package.Version,
                        nugetConfigFile: temporaryConfig?.ConfigFile,
                        nugetSource: selectedTemplateDetails.Package.Source,
                        force: true,
                        options: options,
                        cancellationToken: cancellationToken);
                });
            
            if (templateInstallResult.ExitCode != 0)
            {
                InteractionService.DisplayError("Failed to install Aspire templates.");
                return ExitCodeConstants.FailedToCreateNewProject;
            }
            
            var createResult = await InteractionService.ShowStatusAsync(
                "Creating Aspire projects from template...",
                async () =>
                {
                    return await _runner.NewProjectAsync(
                        "aspire", 
                        initContext.SolutionName, 
                        tempProjectDir, 
                        [], // No extra args needed for aspire template
                        new DotNetCliRunnerInvocationOptions(), 
                        cancellationToken);
                });
            
            if (createResult != 0)
            {
                return createResult;
            }
 
            // Find the created projects in the temporary directory
            var tempDir = new DirectoryInfo(tempProjectDir);
            var appHostProjects = tempDir.GetDirectories("*.AppHost", SearchOption.TopDirectoryOnly);
            var serviceDefaultsProjects = tempDir.GetDirectories("*.ServiceDefaults", SearchOption.TopDirectoryOnly);
 
            if (appHostProjects.Length == 0 || serviceDefaultsProjects.Length == 0)
            {
                InteractionService.DisplayError("Failed to find created AppHost or ServiceDefaults projects in template output.");
                return ExitCodeConstants.FailedToCreateNewProject;
            }
 
            var appHostProjectDir = appHostProjects[0];
            var serviceDefaultsProjectDir = serviceDefaultsProjects[0];
 
            // Move the projects to the solution directory
            var finalAppHostDir = Path.Combine(initContext.SolutionDirectory.FullName, appHostProjectDir.Name);
            var finalServiceDefaultsDir = Path.Combine(initContext.SolutionDirectory.FullName, serviceDefaultsProjectDir.Name);
 
            Directory.Move(appHostProjectDir.FullName, finalAppHostDir);
            Directory.Move(serviceDefaultsProjectDir.FullName, finalServiceDefaultsDir);
 
            // Add projects to solution
            var addResult = await InteractionService.ShowStatusAsync(
                InitCommandStrings.AddingProjectsToSolution,
                async () =>
                {
                    var appHostProjectFile = new FileInfo(Path.Combine(finalAppHostDir, $"{appHostProjectDir.Name}.csproj"));
                    var serviceDefaultsProjectFile = new FileInfo(Path.Combine(finalServiceDefaultsDir, $"{serviceDefaultsProjectDir.Name}.csproj"));
 
                    var addAppHostResult = await _runner.AddProjectToSolutionAsync(
                        solutionFile, 
                        appHostProjectFile, 
                        new DotNetCliRunnerInvocationOptions(), 
                        cancellationToken);
                    
                    if (addAppHostResult != 0)
                    {
                        return addAppHostResult;
                    }
 
                    var addServiceDefaultsResult = await _runner.AddProjectToSolutionAsync(
                        solutionFile, 
                        serviceDefaultsProjectFile, 
                        new DotNetCliRunnerInvocationOptions(), 
                        cancellationToken);
                    
                    return addServiceDefaultsResult;
                });
            
            if (addResult != 0)
            {
                return addResult;
            }
 
            var appHostProjectFile = new FileInfo(Path.Combine(finalAppHostDir, $"{appHostProjectDir.Name}.csproj"));
            var serviceDefaultsProjectFile = new FileInfo(Path.Combine(finalServiceDefaultsDir, $"{serviceDefaultsProjectDir.Name}.csproj"));
 
            // Add selected projects to appHost
            if (initContext.ExecutableProjectsToAddToAppHost.Count > 0)
            {
                foreach(var project in initContext.ExecutableProjectsToAddToAppHost)
                {
                    var addRefResult = await InteractionService.ShowStatusAsync(
                        $"Adding {project.Name} to AppHost...", async () =>
                        {
                            return await _runner.AddProjectReferenceAsync(
                                appHostProjectFile,
                                project,
                                new DotNetCliRunnerInvocationOptions(),
                                cancellationToken);
                        });
 
                    if (addRefResult != 0)
                    {
                        InteractionService.DisplayError($"Failed to add reference to {Path.GetFileNameWithoutExtension(project.Name)}.");
                        return addRefResult;
                    }
                }
            }
 
            // Add ServiceDefaults references to selected projects
            if (initContext.ProjectsToAddServiceDefaultsTo.Count > 0)
            {
                foreach (var project in initContext.ProjectsToAddServiceDefaultsTo)
                {
                    var addRefResult = await InteractionService.ShowStatusAsync(
                        $"Adding ServiceDefaults reference to {project.Name}...", async () =>
                        {
                            return await _runner.AddProjectReferenceAsync(
                                project,
                                serviceDefaultsProjectFile,
                                new DotNetCliRunnerInvocationOptions(),
                                cancellationToken);
                        });
 
                    if (addRefResult != 0)
                    {
                        InteractionService.DisplayError($"Failed to add ServiceDefaults reference to {Path.GetFileNameWithoutExtension(project.Name)}.");
                        return addRefResult;
                    }
                }
            }
 
            await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
            
            InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete);
            return ExitCodeConstants.Success;
        }
        finally
        {
            // Clean up temporary directory
            if (Directory.Exists(tempProjectDir))
            {
                Directory.Delete(tempProjectDir, recursive: true);
            }
        }
    }
 
    private async Task<int> CreateEmptyAppHostAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        ITemplate template;
        
        if (_features.IsFeatureEnabled(KnownFeatures.SingleFileAppHostEnabled, false))
        {
            // Use single-file AppHost template if feature is enabled
            var singleFileTemplate = _templateFactory.GetAllTemplates().FirstOrDefault(t => t.Name == "aspire-apphost-singlefile");
            if (singleFileTemplate is null)
            {
                InteractionService.DisplayError("Single-file AppHost template not found.");
                return ExitCodeConstants.FailedToCreateNewProject;
            }
            template = singleFileTemplate;
        }
        else
        {
            // Use regular AppHost template if single-file feature is not enabled
            var appHostTemplate = _templateFactory.GetAllTemplates().FirstOrDefault(t => t.Name == "aspire-apphost");
            if (appHostTemplate is null)
            {
                InteractionService.DisplayError("AppHost template not found.");
                return ExitCodeConstants.FailedToCreateNewProject;
            }
            template = appHostTemplate;
        }
 
        var result = await template.ApplyTemplateAsync(parseResult, cancellationToken);
        
        if (result.ExitCode == 0)
        {
            await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
            InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete);
        }
 
        return result.ExitCode;
    }
 
    private async Task EvaluateSolutionProjectsAsync(InitContext initContext, CancellationToken cancellationToken)
    {
        var executableProjects = new List<FileInfo>();
        
        foreach (var project in initContext.SolutionProjects)
        {
            // Get both IsAspireHost and OutputType properties in a single call
            var (exitCode, jsonDoc) = await _runner.GetProjectItemsAndPropertiesAsync(
                project,
                [],
                ["IsAspireHost", "OutputType"],
                new DotNetCliRunnerInvocationOptions(),
                cancellationToken);
 
            if (exitCode == 0 && jsonDoc != null)
            {
                var rootElement = jsonDoc.RootElement;
                if (rootElement.TryGetProperty("Properties", out var properties))
                {
                    // Check if this project is an AppHost
                    if (properties.TryGetProperty("IsAspireHost", out var isAspireHostElement))
                    {
                        var isAspireHost = isAspireHostElement.GetString();
                        if (isAspireHost?.Equals("true", StringComparison.OrdinalIgnoreCase) == true)
                        {
                            initContext.AlreadyHasAppHost = true;
                            return;
                        }
                    }
 
                    // Check if this project is executable
                    if (properties.TryGetProperty("OutputType", out var outputTypeElement))
                    {
                        var outputType = outputTypeElement.GetString();
                        if (outputType == "Exe" || outputType == "WinExe")
                        {
                            executableProjects.Add(project);
                        }
                    }
                }
            }
        }
 
        initContext.ExecutableProjects = executableProjects;
    }
 
    private async Task<(NuGetPackage Package, PackageChannel Channel)> GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var channels = await _packagingService.GetChannelsAsync(cancellationToken);
 
        var packagesFromChannels = await InteractionService.ShowStatusAsync("Searching for available template versions...", async () =>
        {
            var results = new List<(NuGetPackage Package, PackageChannel Channel)>();
            var packagesFromChannelsLock = new object();
 
            await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
            {
                var templatePackages = await channel.GetTemplatePackagesAsync(_executionContext.WorkingDirectory, ct);
                lock (packagesFromChannelsLock)
                {
                    results.AddRange(templatePackages.Select(p => (p, channel)));
                }
            });
 
            return results;
        });
 
        if (!packagesFromChannels.Any())
        {
            throw new InvalidOperationException("No template versions found");
        }
 
        var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer);
 
        // Check for explicit version specified via command line
        if (parseResult.GetValue<string>("--version") is { } version)
        {
            var explicitPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => p.Package.Version == version);
            if (explicitPackageFromChannel.Package is not null)
            {
                return explicitPackageFromChannel;
            }
        }
 
        var latestStable = orderedPackagesFromChannels.FirstOrDefault(p => !SemVersion.Parse(p.Package.Version).IsPrerelease);
 
        var templateSelectionMessage = $$"""
                                       # Which version of Aspire do you want to use?
 
                                       Multiple versions of Aspire are available. If you want to use
                                       the latest stable version choose ***{{latestStable.Package.Version}}***.
                                       """;
 
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplayMarkdown(templateSelectionMessage);
        InteractionService.DisplayEmptyLine();
 
        // Prompt user to select from available versions/channels
        var selectedPackageFromChannel = await _prompter.PromptForTemplatesVersionAsync(orderedPackagesFromChannels, cancellationToken);
        return selectedPackageFromChannel;
    }
}
 
/// <summary>
/// Context class for building up a model of the init operation before executing changes.
/// </summary>
internal sealed class InitContext
{
    /// <summary>
    /// The solution file selected for initialization, or null if no solution was found.
    /// </summary>
    public FileInfo? SelectedSolutionFile { get; set; }
 
    /// <summary>
    /// Gets the solution name (without extension) derived from the selected solution file.
    /// </summary>
    public string SolutionName => Path.GetFileNameWithoutExtension(SelectedSolutionFile!.Name);
 
    /// <summary>
    /// Gets the directory containing the solution file.
    /// </summary>
    public DirectoryInfo SolutionDirectory => SelectedSolutionFile!.Directory!;
 
    /// <summary>
    /// Gets the expected directory path for the AppHost project.
    /// </summary>
    public string ExpectedAppHostDirectory => Path.Combine(SolutionDirectory.FullName, $"{SolutionName}.AppHost");
 
    /// <summary>
    /// Gets the expected directory path for the ServiceDefaults project.
    /// </summary>
    public string ExpectedServiceDefaultsDirectory => Path.Combine(SolutionDirectory.FullName, $"{SolutionName}.ServiceDefaults");
 
    /// <summary>
    /// All projects in the solution.
    /// </summary>
    public IReadOnlyList<FileInfo> SolutionProjects { get; set; } = Array.Empty<FileInfo>();
 
    /// <summary>
    /// Indicates whether the solution already has an AppHost project.
    /// </summary>
    public bool AlreadyHasAppHost { get; set; }
 
    /// <summary>
    /// List of executable projects found in the solution (excluding the AppHost).
    /// </summary>
    public IReadOnlyList<FileInfo> ExecutableProjects { get; set; } = Array.Empty<FileInfo>();
 
    /// <summary>
    /// Executable projects selected by the user to add to the AppHost.
    /// </summary>
    public IReadOnlyList<FileInfo> ExecutableProjectsToAddToAppHost { get; set; } = Array.Empty<FileInfo>();
 
    /// <summary>
    /// Projects selected by the user to add ServiceDefaults reference to.
    /// </summary>
    public IReadOnlyList<FileInfo> ProjectsToAddServiceDefaultsTo { get; set; } = Array.Empty<FileInfo>();
}