Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/specs/install-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tool-path>/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/<version>/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.
126 changes: 126 additions & 0 deletions src/Aspire.Cli/Acquisition/InstallSourceDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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);
}
}
26 changes: 17 additions & 9 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddSingleton<IPackagingService, PackagingService>();
builder.Services.AddSingleton<IBundlePayloadProvider, EmbeddedBundlePayloadProvider>();
builder.Services.AddSingleton<IInstallSidecarReader, InstallSidecarReader>();
builder.Services.AddSingleton<InstallSourceDetector>();
builder.Services.AddSingleton<IPeerInstallProbe, PeerInstallProbe>();
builder.Services.AddSingleton<IInstallationCandidateSource, PathInstallationCandidateSource>();
builder.Services.AddSingleton<IInstallationCandidateSource, ReleasePrefixInstallationCandidateSource>();
Expand Down Expand Up @@ -1164,17 +1165,9 @@ public static async Task<int> 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<InstallSourceDetector>());
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;
Expand Down Expand Up @@ -1298,6 +1291,21 @@ public static async Task<int> 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);
Comment thread
DamianEdwards marked this conversation as resolved.
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
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Telemetry/TelemetryConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ internal static class Tags
/// </summary>
public const string CliBuildId = "aspire.cli.build_id";

/// <summary>
/// Tag for the mechanism that installed the running CLI.
/// </summary>
public const string InstallSource = "aspire.cli.install.source";

/// <summary>
/// Tag for the CLI's effective identity version. This is the version the CLI is
/// behaving as — which honors <c>ASPIRE_CLI_VERSION</c> / the sidecar config — and may
Expand Down
12 changes: 6 additions & 6 deletions src/Aspire.Cli/Utils/NpmInstallDetection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,22 @@ 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;
}

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;
Expand Down
Loading
Loading