From 3d6bae9cdb2643bd497d3c1031a72cafdc62846f Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Tue, 29 Sep 2026 15:58:39 -0700 Subject: [PATCH 1/2] Add CLI installation source to main telemetry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/specs/install-routes.md | 27 ++ .../Acquisition/InstallSourceDetector.cs | 123 +++++++++ src/Aspire.Cli/Program.cs | 26 +- .../Telemetry/TelemetryConstants.cs | 5 + src/Aspire.Cli/Utils/NpmInstallDetection.cs | 12 +- .../Acquisition/InstallSourceDetectorTests.cs | 260 ++++++++++++++++++ tests/Aspire.Cli.Tests/ProgramTests.cs | 76 +++++ .../TestServices/TestWindowsRegistryReader.cs | 4 +- 8 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 src/Aspire.Cli/Acquisition/InstallSourceDetector.cs create mode 100644 tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs diff --git a/docs/specs/install-routes.md b/docs/specs/install-routes.md index 206ae8ef41b..9f3859025e9 100644 --- a/docs/specs/install-routes.md +++ b/docs/specs/install-routes.md @@ -95,3 +95,30 @@ Two mechanical checks guard the contract: > **Discovery scope (dotnet-tool route).** Install discovery walks the default `dotnet tool install -g` location at `~/.dotnet/tools/.store/aspire.cli` only. Custom `--tool-path` installs are not discovered today: the dotnet CLI has no machine-wide registry of arbitrary `--tool-path` installs to enumerate, and walking the filesystem would balloon the cost of `aspire doctor`. Users with a custom-`--tool-path` install can confirm it directly with `/aspire doctor --self`. For read-only install discovery (`aspire doctor --format json`), sidecar existence is the trust signal for peer probing. A candidate with any readable sidecar is probed even when `source` is not in the known route table; the raw `source` string is surfaced as the installation `route` so future package-manager routes can appear before this consumer updates. Sidecar-less, unreadable, or malformed candidates are listed without executing the binary. + +## Install-source telemetry + +The `aspire/cli/main` event reports `aspire.cli.install.source` for the running +executable, independently of its effective release channel +(`aspire.cli.identity.channel`). Values are `script`, `pr`, `localhive`, +`winget`, `brew`, `dotnet-tool`, `nix`, `npm`, `mise`, or `unknown`. Bash and +PowerShell installers both report `script`. + +`InstallSourceDetector` resolves the executable's symlink and prefers a known +sidecar source. Without one, it checks WinGet's registry ownership, the npm +launcher's package marker, mise's installation path, and .NET-tool detection, +in that order. WinGet attribution does not require writing a sidecar, so it +also works before bundle extraction and on read-only installations. + +mise detection matches `mise/installs/aspire//aspire` (also allowing a +`bin` directory beneath the version, or the `github-microsoft-aspire` tool +directory for explicit GitHub-backend installs). Custom roots are recognized through +`MISE_INSTALLS_DIR`, `MISE_SYSTEM_INSTALLS_DIR`, or `MISE_DATA_DIR`, but only +when the executable actually occupies the matching Aspire installation path. +Unrecognized layouts, missing provenance, and failed probes report `unknown`; +arbitrary sidecar values and installation paths are never exported. + +Detection runs only when a main activity is created. It does not launch package +managers, enumerate other Aspire installations, or modify installation files. +The property is not added to other activities or to the separately reported +agent-hook event. diff --git a/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs b/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs new file mode 100644 index 00000000000..125444376ff --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs @@ -0,0 +1,123 @@ +// 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; + +/// +/// Identifies the running CLI's install source without modifying the installation. +/// +internal sealed class InstallSourceDetector( + IProcessPathProvider processPathProvider, + IInstallSidecarReader sidecarReader, + IWindowsRegistryReader registryReader, + IEnvironment environment, + ILogger 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 /installs/aspire//aspire (or /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 static bool MatchesConfiguredDirectory(string? actualDirectory, string? configuredDirectory, StringComparison comparison) + { + return actualDirectory is not null && + !string.IsNullOrWhiteSpace(configuredDirectory) && + Path.IsPathFullyQualified(configuredDirectory) && + string.Equals( + actualDirectory, + Path.TrimEndingDirectorySeparator(Path.GetFullPath(configuredDirectory)), + comparison); + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 11369b28043..16aec69462f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -530,6 +530,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -1164,17 +1165,9 @@ public static async Task Main(string[] args) // Agent events must not also count as ordinary CLI invocations. using var mainActivity = isAgentTelemetryInvocation ? null - : telemetry.StartReportedActivity(TelemetryConstants.Activities.Main, ActivityKind.Internal); + : StartMainActivity(telemetry, app.Services.GetRequiredService()); ProfileCaptureService.ProfileCaptureSession? profileCaptureSession = null; - if (mainActivity != null) - { - var currentProcess = Process.GetCurrentProcess(); - mainActivity.SetStartTime(currentProcess.StartTime); - mainActivity.AddTag(TelemetryConstants.Tags.ProcessPid, currentProcess.Id); - mainActivity.AddTag(TelemetryConstants.Tags.ProcessExecutableName, "aspire"); - } - try { var exitCode = CliExitCodes.Success; @@ -1298,6 +1291,21 @@ public static async Task Main(string[] args) } } + internal static Activity? StartMainActivity(AspireCliTelemetry telemetry, InstallSourceDetector installSourceDetector) + { + var activity = telemetry.StartReportedActivity(TelemetryConstants.Activities.Main, ActivityKind.Internal); + if (activity is not null) + { + using var currentProcess = Process.GetCurrentProcess(); + activity.SetStartTime(currentProcess.StartTime); + activity.AddTag(TelemetryConstants.Tags.ProcessPid, currentProcess.Id); + activity.AddTag(TelemetryConstants.Tags.ProcessExecutableName, "aspire"); + activity.SetTag(TelemetryConstants.Tags.InstallSource, installSourceDetector.Detect()); + } + + return activity; + } + internal static void InitializeCommandTelemetry(Command command, TelemetryManager manager, AspireCliTelemetry telemetry) { // Like package prefetching, expensive telemetry startup is command-controlled. A hook may diff --git a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs index 9351feacbdf..6f4a685a000 100644 --- a/src/Aspire.Cli/Telemetry/TelemetryConstants.cs +++ b/src/Aspire.Cli/Telemetry/TelemetryConstants.cs @@ -73,6 +73,11 @@ internal static class Tags /// public const string CliBuildId = "aspire.cli.build_id"; + /// + /// Tag for the mechanism that installed the running CLI. + /// + public const string InstallSource = "aspire.cli.install.source"; + /// /// Tag for the CLI's effective identity version. This is the version the CLI is /// behaving as — which honors ASPIRE_CLI_VERSION / the sidecar config — and may diff --git a/src/Aspire.Cli/Utils/NpmInstallDetection.cs b/src/Aspire.Cli/Utils/NpmInstallDetection.cs index e8d50e73e13..5211b9aaec7 100644 --- a/src/Aspire.Cli/Utils/NpmInstallDetection.cs +++ b/src/Aspire.Cli/Utils/NpmInstallDetection.cs @@ -37,15 +37,10 @@ internal static bool IsRunningFromNpm() var env = s_environmentOverride.Value ?? ProcessEnvironmentReader.Instance; var packageName = env.GetEnvironmentVariable(PackageEnvironmentVariableName); - if (string.IsNullOrWhiteSpace(packageName)) - { - return null; - } - // The launcher always writes the canonical "@microsoft/aspire-cli" package name. // Reject anything else so an unrelated env var collision does not flip the CLI // into the npm self-update path. - if (!string.Equals(packageName, ExpectedPackageName, StringComparison.Ordinal)) + if (!IsNpmPackage(packageName)) { return null; } @@ -53,6 +48,11 @@ internal static bool IsRunningFromNpm() return $"npm install -g {ExpectedPackageName}@latest"; } + internal static bool IsNpmPackage(string? packageName) + { + return string.Equals(packageName, ExpectedPackageName, StringComparison.Ordinal); + } + internal static string? GetNpmPackageVersion() { var env = s_environmentOverride.Value ?? ProcessEnvironmentReader.Instance; diff --git a/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs b/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs new file mode 100644 index 00000000000..e4a162cbb17 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs @@ -0,0 +1,260 @@ +// 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.Acquisition; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Acquisition; + +public class InstallSourceDetectorTests(ITestOutputHelper outputHelper) +{ + [Theory] + [InlineData("script")] + [InlineData("pr")] + [InlineData("winget")] + [InlineData("brew")] + [InlineData("dotnet-tool")] + [InlineData("localhive")] + [InlineData("nix")] + public void Detect_PrefersKnownSidecarToOtherSignals(string source) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var binaryDirectory = workspace.CreateDirectory(Path.Combine("mise", "installs", "aspire", "13.5.0")); + File.WriteAllText(Path.Combine(binaryDirectory.FullName, InstallSidecarReader.SidecarFileName), $$"""{"source":"{{source}}"}"""); + var environment = TestEnvironment.CreateWindows(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName + }); + var detector = CreateDetector(Path.Combine(binaryDirectory.FullName, "aspire.exe"), environment, new TestWindowsRegistryReader(true)); + + Assert.Equal(source, detector.Detect()); + } + + [Theory] + [InlineData(null)] + [InlineData("{bad-json")] + [InlineData("""{"source":"future-route-with-private-data"}""")] + [InlineData("""{"source":42}""")] + [InlineData("""{"source":""}""")] + public void Detect_ReturnsUnknownWithoutRecognizedProvenance(string? sidecar) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + if (sidecar is not null) + { + File.WriteAllText(Path.Combine(workspace.Path, InstallSidecarReader.SidecarFileName), sidecar); + } + var detector = CreateDetector(Path.Combine(workspace.Path, "aspire"), TestEnvironment.CreateLinux()); + + Assert.Equal("unknown", detector.Detect()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("aspire")] + [InlineData("invalid\0path")] + public void Detect_ReturnsUnknownForUnavailableProcessPath(string? processPath) + { + var detector = CreateDetector(processPath, TestEnvironment.CreateLinux()); + + Assert.Equal("unknown", detector.Detect()); + } + + [Fact] + public void Detect_DoesNotAttributeManagedHostFromInheritedNpmMarker() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName + }); + var detector = CreateDetector(Path.Combine(workspace.Path, "dotnet"), environment); + + Assert.Equal("unknown", detector.Detect()); + } + + [Theory] + [InlineData("@microsoft/aspire-cli", "npm")] + [InlineData("@Microsoft/Aspire-Cli", "unknown")] + [InlineData("aspire-cli", "unknown")] + [InlineData("", "unknown")] + public void Detect_RecognizesOnlyCanonicalNpmPackage(string packageName, string expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = packageName + }); + var detector = CreateDetector(Path.Combine(workspace.Path, "aspire"), environment); + + Assert.Equal(expected, detector.Detect()); + } + + [Theory] + [InlineData(true, "winget")] + [InlineData(false, "unknown")] + public void Detect_ProbesWingetWithoutWritingSidecar(bool registryClaim, string expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var processPath = Path.Combine(workspace.Path, "aspire.exe"); + var registry = new TestWindowsRegistryReader + { + ProbeCallback = path => + { + Assert.Equal(processPath, path); + return registryClaim; + } + }; + var detector = CreateDetector(processPath, TestEnvironment.CreateWindows(), registry); + + Assert.Equal(expected, detector.Detect()); + Assert.Empty(Directory.GetFiles(workspace.Path)); + } + + [Fact] + public void Detect_DoesNotProbeRegistryOnUnix() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var registry = new TestWindowsRegistryReader + { + ProbeCallback = _ => throw new InvalidOperationException("The registry must not be read on Unix.") + }; + var detector = CreateDetector(Path.Combine(workspace.Path, "aspire"), TestEnvironment.CreateLinux(), registry); + + Assert.Equal("unknown", detector.Detect()); + } + + [Fact] + public void Detect_ReturnsUnknownWhenProbeCannotReadInstallation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var registry = new TestWindowsRegistryReader + { + ProbeCallback = _ => throw new UnauthorizedAccessException() + }; + var detector = CreateDetector(Path.Combine(workspace.Path, "aspire.exe"), TestEnvironment.CreateWindows(), registry); + + Assert.Equal("unknown", detector.Detect()); + } + + [Theory] + [InlineData(".local/share/mise/installs/aspire/13.5.0/aspire", "mise")] + [InlineData("AppData/Local/mise/installs/aspire/13.5.0/bin/aspire", "mise")] + [InlineData("usr/local/share/mise/installs/aspire/13.5.0/aspire", "mise")] + [InlineData("mise/installs/aspire/latest/aspire", "mise")] + [InlineData("mise/installs/github-microsoft-aspire/13.5.0/aspire", "mise")] + [InlineData("mise/installs/github-microsoft-aspire/13.5.0/bin/aspire", "mise")] + [InlineData("mise/installs/aspire/13.5.0/tools/aspire", "unknown")] + [InlineData("mise/installs/another-tool/13.5.0/aspire", "unknown")] + [InlineData("mise/installs/aspire/aspire", "unknown")] + [InlineData("mise/installs/aspire-extra/13.5.0/aspire", "unknown")] + [InlineData("mise/installs/github-other-aspire/13.5.0/aspire", "unknown")] + [InlineData("not-mise/installs/aspire/13.5.0/aspire", "unknown")] + [InlineData("mise/downloads/aspire/13.5.0/aspire", "unknown")] + [InlineData("mise/shims/aspire", "unknown")] + public void Detect_MatchesMiseInstallationBoundaries(string relativePath, string expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var processPath = Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var detector = CreateDetector(processPath, TestEnvironment.CreateLinux()); + + Assert.Equal(expected, detector.Detect()); + } + + [Theory] + [InlineData("MISE_INSTALLS_DIR", "custom", "custom/aspire/13.5.0/aspire", "mise")] + [InlineData("MISE_SYSTEM_INSTALLS_DIR", "custom", "custom/aspire/13.5.0/aspire", "mise")] + [InlineData("MISE_DATA_DIR", "custom", "custom/installs/aspire/13.5.0/aspire", "mise")] + [InlineData("MISE_INSTALLS_DIR", "custom", "custom/github-microsoft-aspire/13.5.0/aspire", "mise")] + [InlineData("MISE_DATA_DIR", "custom", "custom/downloads/aspire/13.5.0/aspire", "unknown")] + [InlineData("MISE_INSTALLS_DIR", "custom", "custom-extra/aspire/13.5.0/aspire", "unknown")] + [InlineData("MISE_DATA_DIR", "custom", "unrelated/aspire", "unknown")] + [InlineData("MISE_INSTALLS_DIR", "custom", "custom/other/13.5.0/aspire", "unknown")] + public void Detect_RequiresMatchingPathForMiseOverrides(string variable, string root, string relativePath, string expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [variable] = Path.Combine(workspace.Path, root) + Path.DirectorySeparatorChar + }); + var processPath = Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var detector = CreateDetector(processPath, environment); + + Assert.Equal(expected, detector.Detect()); + } + + [Theory] + [InlineData(true, "mise")] + [InlineData(false, "unknown")] + public void Detect_UsesPlatformPathComparisonForMise(bool windows, string expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var environment = windows ? TestEnvironment.CreateWindows() : TestEnvironment.CreateLinux(); + var processPath = Path.Combine(workspace.Path, "MISE", "INSTALLS", "ASPIRE", "13.5.0", windows ? "ASPIRE.EXE" : "aspire"); + var detector = CreateDetector(processPath, environment); + + Assert.Equal(expected, detector.Detect()); + } + + [Theory] + [InlineData(null)] + [InlineData("{bad-json")] + [InlineData("""{"source":"unrecognized"}""")] + public void Detect_UsesNpmMarkerBeforeMisePathWhenSidecarDoesNotIdentifySource(string? sidecar) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var directory = workspace.CreateDirectory(Path.Combine("mise", "installs", "aspire", "13.5.0")); + if (sidecar is not null) + { + File.WriteAllText(Path.Combine(directory.FullName, InstallSidecarReader.SidecarFileName), sidecar); + } + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName + }); + var detector = CreateDetector(Path.Combine(directory.FullName, "aspire"), environment); + + Assert.Equal("npm", detector.Detect()); + } + + [Theory] + [InlineData(".dotnet/tools/aspire")] + [InlineData("custom/.store/aspire.cli/13.5.0/aspire.cli.linux-x64/13.5.0/tools/any/linux-x64/aspire")] + public void Detect_RecognizesDotNetToolsWithoutSidecar(string relativePath) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var processPath = Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var detector = CreateDetector(processPath, TestEnvironment.CreateLinux()); + + Assert.Equal("dotnet-tool", detector.Detect()); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Symlink creation requires additional privileges on Windows.")] + public void Detect_ReadsSidecarBesideResolvedExecutable() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var installDirectory = workspace.CreateDirectory("installed"); + var processPath = Path.Combine(installDirectory.FullName, "aspire"); + File.WriteAllText(processPath, string.Empty); + File.WriteAllText(Path.Combine(installDirectory.FullName, InstallSidecarReader.SidecarFileName), """{"source":"brew"}"""); + var linkPath = Path.Combine(workspace.Path, "aspire"); + File.CreateSymbolicLink(linkPath, processPath); + var detector = CreateDetector(linkPath, TestEnvironment.CreateLinux()); + + Assert.Equal("brew", detector.Detect()); + } + + private InstallSourceDetector CreateDetector(string? processPath, IEnvironment environment, IWindowsRegistryReader? registry = null) + { + return new InstallSourceDetector( + new TestProcessPathProvider(processPath), + CliTestHelper.CreateSidecarReader(outputHelper), + registry ?? new TestWindowsRegistryReader(), + environment, + NullLogger.Instance); + } +} diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs index 741e784188f..15ce33b2f8d 100644 --- a/tests/Aspire.Cli.Tests/ProgramTests.cs +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -1,21 +1,97 @@ // 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 System.Reflection; using System.Text; +using Aspire.Cli.Acquisition; using Aspire.Cli.Configuration; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Tests.Telemetry; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Aspire.Shared; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Spectre.Console; namespace Aspire.Cli.Tests; public class ProgramTests(ITestOutputHelper outputHelper) { + [Theory] + [InlineData("script")] + [InlineData("winget")] + [InlineData("brew")] + [InlineData("dotnet-tool")] + [InlineData("nix")] + [InlineData("pr")] + [InlineData("localhive")] + [InlineData("npm")] + [InlineData("mise")] + [InlineData("unknown")] + public void StartMainActivity_ReportsInstallSource(string source) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var fixture = new TelemetryFixture(); + if (source is not ("unknown" or "npm" or "mise")) + { + File.WriteAllText(Path.Combine(workspace.Path, InstallSidecarReader.SidecarFileName), $$"""{"source":"{{source}}"}"""); + } + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = source == "npm" ? NpmInstallDetection.ExpectedPackageName : null + }); + var processPath = source == "mise" + ? Path.Combine(workspace.Path, "mise", "installs", "aspire", "13.5.0", "aspire") + : Path.Combine(workspace.Path, "aspire"); + var detector = new InstallSourceDetector( + new TestProcessPathProvider(processPath), + CliTestHelper.CreateSidecarReader(outputHelper), + new TestWindowsRegistryReader(), + environment, + NullLogger.Instance); + + using var activity = Program.StartMainActivity(fixture.Telemetry, detector); + + Assert.NotNull(activity); + Assert.Equal(TelemetryConstants.Activities.Main, activity.OperationName); + Assert.Equal(source, activity.GetTagItem("aspire.cli.install.source")); + Assert.Equal(Environment.ProcessId, activity.GetTagItem(TelemetryConstants.Tags.ProcessPid)); + Assert.Equal("aspire", activity.GetTagItem(TelemetryConstants.Tags.ProcessExecutableName)); + activity.Stop(); + Assert.NotNull(fixture.CapturedActivity); + Assert.Same(activity, fixture.CapturedActivity); + Assert.Equal(source, fixture.CapturedActivity.GetTagItem(TelemetryConstants.Tags.InstallSource)); + + using var otherActivity = fixture.Telemetry.StartReportedActivity("other"); + Assert.NotNull(otherActivity); + Assert.Null(otherActivity.GetTagItem(TelemetryConstants.Tags.InstallSource)); + } + + [Fact] + public void StartMainActivity_DoesNotProbeWhenActivityIsNotCreated() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.None); + var detector = new InstallSourceDetector( + new TestProcessPathProvider(Path.Combine(workspace.Path, "aspire.exe")), + CliTestHelper.CreateSidecarReader(outputHelper), + new TestWindowsRegistryReader + { + ProbeCallback = _ => throw new InvalidOperationException("No install probe should run without an activity.") + }, + TestEnvironment.CreateWindows(), + NullLogger.Instance); + + using var activity = Program.StartMainActivity(fixture.Telemetry, detector); + + Assert.Null(activity); + } + [Fact] public void ParseLogFileOption_ReturnsNull_WhenArgsAreNull() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestWindowsRegistryReader.cs b/tests/Aspire.Cli.Tests/TestServices/TestWindowsRegistryReader.cs index 39360867a3b..b5c6af230c8 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestWindowsRegistryReader.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestWindowsRegistryReader.cs @@ -7,5 +7,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestWindowsRegistryReader(bool hasWingetAspireUninstallEntry = false) : IWindowsRegistryReader { - public bool HasWingetAspireUninstallEntry(string processPath) => hasWingetAspireUninstallEntry; + public Func? ProbeCallback { get; init; } + + public bool HasWingetAspireUninstallEntry(string processPath) => ProbeCallback?.Invoke(processPath) ?? hasWingetAspireUninstallEntry; } \ No newline at end of file From b716d2f95f10ed0781308d011e5e460fe2abda15 Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Tue, 29 Sep 2026 17:17:39 -0700 Subject: [PATCH 2/2] Normalize custom mise roots for install-source telemetry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Acquisition/InstallSourceDetector.cs | 7 ++++-- .../Acquisition/InstallSourceDetectorTests.cs | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs b/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs index 125444376ff..23e18e4c250 100644 --- a/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs +++ b/src/Aspire.Cli/Acquisition/InstallSourceDetector.cs @@ -110,14 +110,17 @@ private bool IsMiseInstall(string binaryDirectory, StringComparison comparison) MatchesConfiguredDirectory(installsDirectory.Parent?.FullName, environment.GetEnvironmentVariable("MISE_DATA_DIR"), comparison)); } - private static bool MatchesConfiguredDirectory(string? actualDirectory, string? configuredDirectory, StringComparison 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(Path.GetFullPath(configuredDirectory)), + Path.TrimEndingDirectorySeparator(resolvedDirectory), comparison); } } diff --git a/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs b/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs index e4a162cbb17..cfd7fb0d592 100644 --- a/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs +++ b/tests/Aspire.Cli.Tests/Acquisition/InstallSourceDetectorTests.cs @@ -186,6 +186,30 @@ public void Detect_RequiresMatchingPathForMiseOverrides(string variable, string Assert.Equal(expected, detector.Detect()); } + [Theory] + [InlineData("MISE_INSTALLS_DIR", true)] + [InlineData("MISE_INSTALLS_DIR", false)] + [InlineData("MISE_SYSTEM_INSTALLS_DIR", true)] + [InlineData("MISE_SYSTEM_INSTALLS_DIR", false)] + [InlineData("MISE_DATA_DIR", true)] + [InlineData("MISE_DATA_DIR", false)] + [SkipOnPlatform(TestPlatforms.Windows | TestPlatforms.Linux, "Firmlink normalization only applies on macOS.")] + public void Detect_NormalizesMacOSMiseRootAliases(string variable, bool configuredRootUsesPrivatePrefix) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var root = Path.Combine("/tmp", workspace.WorkspaceRoot.Name, "custom"); + var configuredRoot = configuredRootUsesPrivatePrefix ? "/private" + root : root; + var processRoot = configuredRootUsesPrivatePrefix ? root : "/private" + root; + var installsRoot = variable == "MISE_DATA_DIR" ? Path.Combine(processRoot, "installs") : processRoot; + var environment = TestEnvironment.CreateMacOS(new Dictionary + { + [variable] = configuredRoot + Path.DirectorySeparatorChar + }); + var detector = CreateDetector(Path.Combine(installsRoot, "aspire", "13.5.0", "aspire"), environment); + + Assert.Equal("mise", detector.Detect()); + } + [Theory] [InlineData(true, "mise")] [InlineData(false, "unknown")]