File: ResolveAspireCliInvocation.cs
Web Access
Project: src\src\Aspire.Hosting.Tasks\Aspire.Hosting.Tasks.csproj (Aspire.Hosting.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
 
namespace Aspire.Hosting.Tasks;
 
/// <summary>
/// Selects the command used to delegate an AppHost launch to the Aspire CLI.
/// </summary>
public sealed class ResolveAspireCliInvocation : Microsoft.Build.Utilities.Task
{
    /// <summary>
    /// Gets or sets an explicitly configured Aspire CLI path.
    /// </summary>
    public string? AspireCliPath { get; set; }
 
    /// <summary>
    /// Gets or sets the requested Aspire CLI invocation mode.
    /// </summary>
    public string? AspireCliInvocationMode { get; set; }
 
    /// <summary>
    /// Gets or sets the <c>PATH</c> value used to locate commands.
    /// </summary>
    public string? PathEnvironmentVariable { get; set; }
 
    /// <summary>
    /// Gets the selected invocation mode.
    /// </summary>
    [Output]
    public string? ResolvedInvocationMode { get; set; }
 
    /// <summary>
    /// Gets the Aspire CLI command found on <c>PATH</c>.
    /// </summary>
    [Output]
    public string? ResolvedAspireCliPath { get; set; }
 
    /// <summary>
    /// Gets the DNX command found on <c>PATH</c>.
    /// </summary>
    [Output]
    public string? ResolvedDnxPath { get; set; }
 
    /// <summary>
    /// Gets the executable that hosts the selected DNX command.
    /// </summary>
    [Output]
    public string? ResolvedDnxHostPath { get; set; }
 
    /// <summary>
    /// Gets the arguments that select DNX on <see cref="ResolvedDnxHostPath"/>.
    /// </summary>
    [Output]
    public string? ResolvedDnxHostArguments { get; set; }
 
    /// <summary>
    /// Gets the individual arguments that select DNX on <see cref="ResolvedDnxHostPath"/>.
    /// </summary>
    [Output]
    public ITaskItem[] ResolvedDnxHostArgumentItems { get; set; } = [];
 
    public override bool Execute()
    {
        if (!string.IsNullOrWhiteSpace(AspireCliPath))
        {
            ResolvedInvocationMode = "Aspire";
            ResolvedAspireCliPath = AspireCliPath;
            return true;
        }
 
        var forceDnx =
            string.Equals(AspireCliInvocationMode, "Dnx", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(AspireCliInvocationMode, "DnxPinned", StringComparison.OrdinalIgnoreCase);
        if (!forceDnx)
        {
            ResolvedAspireCliPath = CommandPathResolver.ResolveFromPath("aspire", PathEnvironmentVariable);
            if (ResolvedAspireCliPath is not null)
            {
                ResolvedInvocationMode = "Aspire";
            }
        }
 
        foreach (var dnxPath in CommandPathResolver.EnumerateFromPath("dnx", PathEnvironmentVariable))
        {
            if (TryResolveDnxHost(dnxPath))
            {
                ResolvedDnxPath = dnxPath;
                break;
            }
 
            Log.LogMessage(MessageImportance.Low, "DNX command '{0}' could not be mapped to an executable host.", dnxPath);
        }
 
        if (forceDnx || (ResolvedInvocationMode is null && ResolvedDnxPath is not null))
        {
            // Keep DNX selected when it was explicitly requested but unavailable. The run
            // preflight emits the actionable diagnostic, while ordinary builds remain valid.
            ResolvedInvocationMode = "Dnx";
        }
 
        return true;
    }
 
    private bool TryResolveDnxHost(string dnxPath)
    {
        if (!IsWindowsCommandShim(dnxPath))
        {
            ResolvedDnxHostPath = dnxPath;
            return true;
        }
 
        var dotnetRoot = Path.GetDirectoryName(dnxPath);
        if (string.IsNullOrEmpty(dotnetRoot))
        {
            return false;
        }
 
        var dotnetPath = Path.Combine(dotnetRoot, "dotnet.exe");
        if (!File.Exists(dotnetPath) || !TryGetLatestSdkVersion(dotnetPath, out var sdkVersion))
        {
            return false;
        }
 
        var sdkPath = Path.Combine(dotnetRoot, "sdk", sdkVersion!, "dotnet.dll");
        if (!File.Exists(sdkPath))
        {
            return false;
        }
 
        // The SDK's dnx.cmd intentionally uses `dotnet exec <sdk>\dotnet.dll dnx` so a
        // global.json that selects an older SDK cannot hide the DNX command. Preserve that
        // behavior while bypassing cmd.exe, which would reinterpret forwarded AppHost arguments.
        ResolvedDnxHostPath = dotnetPath;
        ResolvedDnxHostArguments = $"exec \"{sdkPath}\" dnx";
        ResolvedDnxHostArgumentItems =
        [
            new TaskItem("exec"),
            new TaskItem(sdkPath),
            new TaskItem("dnx")
        ];
        return true;
    }
 
    private bool TryGetLatestSdkVersion(string dotnetPath, out string? sdkVersion)
    {
        var startInfo = new ProcessStartInfo(dotnetPath, "--list-sdks")
        {
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
 
        try
        {
            using var process = Process.Start(startInfo);
            if (process is null)
            {
                sdkVersion = null;
                return false;
            }
 
            // Drain both redirected streams concurrently so a full pipe cannot block the process.
            var outputTask = process.StandardOutput.ReadToEndAsync();
            var errorTask = process.StandardError.ReadToEndAsync();
            if (!process.WaitForExit(milliseconds: 5000))
            {
                try
                {
                    process.Kill();
                    process.WaitForExit();
                }
                catch (InvalidOperationException)
                {
                    // The process exited between the timeout and termination attempt.
                }
 
                Log.LogMessage(MessageImportance.Low, "'{0} --list-sdks' timed out after 5 seconds.", dotnetPath);
                sdkVersion = null;
                return false;
            }
 
            var output = outputTask.GetAwaiter().GetResult();
            var error = errorTask.GetAwaiter().GetResult();
            if (process.ExitCode != 0)
            {
                Log.LogMessage(MessageImportance.Low, "'{0} --list-sdks' failed with exit code {1}: {2}", dotnetPath, process.ExitCode, error);
                sdkVersion = null;
                return false;
            }
 
            // `dotnet --list-sdks` is ordered from oldest to newest and emits:
            //   10.0.100 [C:\Program Files\dotnet\sdk]
            var lastLine = output
                .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
                .LastOrDefault();
            var separatorIndex = lastLine?.IndexOf(' ') ?? -1;
            sdkVersion = separatorIndex > 0 ? lastLine!.Substring(0, separatorIndex) : null;
            return sdkVersion is not null;
        }
        catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
        {
            Log.LogMessage(MessageImportance.Low, "Failed to inspect SDKs through '{0}': {1}", dotnetPath, ex.Message);
            sdkVersion = null;
            return false;
        }
    }
 
    private static bool IsWindowsCommandShim(string path)
    {
        return Path.DirectorySeparatorChar == '\\'
            && (path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)
                || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase));
    }
}