File: Acquisition\InstallSourceDetector.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.Security;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Acquisition;
 
/// <summary>
/// Identifies the running CLI's install source without modifying the installation.
/// </summary>
internal sealed class InstallSourceDetector(
    IProcessPathProvider processPathProvider,
    IInstallSidecarReader sidecarReader,
    IWindowsRegistryReader registryReader,
    IEnvironment environment,
    ILogger<InstallSourceDetector> logger)
{
    public string Detect()
    {
        try
        {
            var processPath = processPathProvider.ProcessPath;
            if (string.IsNullOrEmpty(processPath) || !Path.IsPathFullyQualified(processPath))
            {
                logger.LogDebug("Install-source detection skipped because the process path is unavailable or not absolute.");
                return "unknown";
            }
 
            var resolvedPath = CliPathHelper.ResolveSymlinkToFullPath(processPath, logger);
            if (resolvedPath is null)
            {
                return "unknown";
            }
 
            var binaryDirectory = Path.GetDirectoryName(resolvedPath);
            var comparison = environment.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
            if (string.IsNullOrEmpty(binaryDirectory) ||
                !string.Equals(Path.GetFileName(resolvedPath), environment.IsWindows() ? "aspire.exe" : "aspire", comparison))
            {
                return "unknown";
            }
 
            if (sidecarReader.TryRead(binaryDirectory) is InstallSidecarReadResult.Ok sidecar &&
                sidecar.Info.Source.ToWireString() is { } source)
            {
                // Never report RawSource: sidecars can contain arbitrary strings, but telemetry
                // must stay within the known install-source vocabulary.
                return source;
            }
 
            // WinGet does not stamp a sidecar until bundle extraction or doctor runs. Read the
            // registry directly so even the first --version invocation works on a read-only install.
            if (environment.IsWindows() && registryReader.HasWingetAspireUninstallEntry(resolvedPath))
            {
                return InstallSourceExtensions.WingetWire;
            }
 
            if (NpmInstallDetection.IsNpmPackage(environment.GetEnvironmentVariable(NpmInstallDetection.PackageEnvironmentVariableName)))
            {
                return "npm";
            }
 
            if (IsMiseInstall(binaryDirectory, comparison))
            {
                return "mise";
            }
 
            if (DotNetToolDetection.IsRunningAsDotNetTool(resolvedPath))
            {
                return InstallSourceExtensions.DotnetToolWire;
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or SecurityException)
        {
            logger.LogDebug(ex, "Could not detect the CLI install source.");
        }
 
        return "unknown";
    }
 
    private bool IsMiseInstall(string binaryDirectory, StringComparison comparison)
    {
        // mise uses <data>/installs/aspire/<version>/aspire (or <version>/bin/aspire).
        // Match the tool and directory boundaries, not merely a "mise" substring or the
        // presence of MISE_* variables in a shell that could launch an unrelated CLI.
        // https://mise.jdx.dev/directories.html
        var directory = new DirectoryInfo(binaryDirectory);
        var versionDirectory = string.Equals(directory.Name, "bin", comparison) ? directory.Parent : directory;
        var toolDirectory = versionDirectory?.Parent;
        var installsDirectory = toolDirectory?.Parent;
        if (toolDirectory is null || installsDirectory is null ||
            !(string.Equals(toolDirectory.Name, "aspire", comparison) ||
              string.Equals(toolDirectory.Name, "github-microsoft-aspire", comparison)))
        {
            return false;
        }
 
        // This also covers XDG_DATA_HOME, Windows LOCALAPPDATA, and system installs.
        if (string.Equals(installsDirectory.Name, "installs", comparison) &&
            string.Equals(installsDirectory.Parent?.Name, "mise", comparison))
        {
            return true;
        }
 
        return MatchesConfiguredDirectory(installsDirectory.FullName, environment.GetEnvironmentVariable("MISE_INSTALLS_DIR"), comparison) ||
            MatchesConfiguredDirectory(installsDirectory.FullName, environment.GetEnvironmentVariable("MISE_SYSTEM_INSTALLS_DIR"), comparison) ||
            (string.Equals(installsDirectory.Name, "installs", comparison) &&
             MatchesConfiguredDirectory(installsDirectory.Parent?.FullName, environment.GetEnvironmentVariable("MISE_DATA_DIR"), comparison));
    }
 
    private bool MatchesConfiguredDirectory(string? actualDirectory, string? configuredDirectory, StringComparison comparison)
    {
        // Use the executable's canonicalization for the configured root too, including
        // macOS aliases such as /private/tmp/tools and /tmp/tools.
        return actualDirectory is not null &&
            !string.IsNullOrWhiteSpace(configuredDirectory) &&
            Path.IsPathFullyQualified(configuredDirectory) &&
            CliPathHelper.ResolveSymlinkToFullPath(Path.TrimEndingDirectorySeparator(configuredDirectory), logger) is { } resolvedDirectory &&
            string.Equals(
                actualDirectory,
                Path.TrimEndingDirectorySeparator(resolvedDirectory),
                comparison);
    }
}