// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Projects;
internal interface IProjectLocator
{
/// <summary>
/// Finds all candidate AppHost projects in the specified search directory.
/// </summary>
/// <param name="searchDirectory">The directory to search recursively.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost projects with language metadata sorted by full path.</returns>
Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(
DirectoryInfo searchDirectory,
AppHostDiscoveryScope scope,
CancellationToken cancellationToken);
/// <summary>
/// Streams candidate AppHost projects as discovery/validation completes.
/// </summary>
/// <param name="searchDirectory">The directory to search recursively.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="onDirectoryEnumerated">
/// Optional callback invoked synchronously on the discovery thread with the running total of directories
/// enumerated so callers can render progress before validation completes. See
/// <see cref="IAppHostCandidateFinder.FindCandidateFilesAsync"/> for caller obligations.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async stream of candidate AppHost projects in completion order.</returns>
async IAsyncEnumerable<AppHostProjectCandidate> FindAppHostProjectsStreamAsync(
DirectoryInfo searchDirectory,
AppHostDiscoveryScope scope,
Action<int>? onDirectoryEnumerated = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var candidates = await FindAppHostProjectsAsync(searchDirectory, scope, cancellationToken).ConfigureAwait(false);
foreach (var candidate in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
yield return candidate;
}
}
/// <summary>
/// Finds all candidate AppHost projects in the specified search directory up to the specified depth.
/// </summary>
/// <param name="searchDirectory">The directory to search.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="maxDepth">The maximum subdirectory depth to search, where 0 only considers files in <paramref name="searchDirectory"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost projects with language metadata sorted by full path.</returns>
Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, int? maxDepth, CancellationToken cancellationToken)
=> maxDepth is null
? FindAppHostProjectsAsync(searchDirectory, scope, cancellationToken)
: throw new NotSupportedException();
/// <summary>
/// Finds all candidate AppHost project files in the specified search directory, without language metadata.
/// </summary>
/// <param name="searchDirectory">The directory to search recursively.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost project files sorted by full path.</returns>
Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken);
/// <summary>
/// Finds all candidate AppHost project files in the specified search directory up to the specified depth, without language metadata.
/// </summary>
/// <param name="searchDirectory">The directory to search.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="maxDepth">The maximum subdirectory depth to search, where 0 only considers files in <paramref name="searchDirectory"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost project files sorted by full path.</returns>
Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, int? maxDepth, CancellationToken cancellationToken)
=> maxDepth is null
? FindAppHostProjectFilesAsync(searchDirectory, scope, cancellationToken)
: throw new NotSupportedException();
Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default);
Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, bool displayProgress, CancellationToken cancellationToken = default)
=> UseOrFindAppHostProjectFileAsync(projectFile, multipleAppHostProjectsFoundBehavior, createSettingsFile, cancellationToken);
Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken);
/// <summary>
/// Resolves the AppHost project file from Aspire settings, without any user interaction,
/// recursive filesystem scanning, or MSBuild-based validation of the configured path.
/// Returns <c>null</c> when no settings file is found, when the path entry is absent,
/// when the configured file does not exist, or when no registered handler can process it.
/// </summary>
/// <remarks>
/// Unlike <see cref="UseOrFindAppHostProjectFileAsync(FileInfo?, bool, CancellationToken)"/>,
/// this method intentionally does not call into MSBuild to validate the configured AppHost.
/// Callers like <c>aspire update</c> need to operate on an AppHost whose pinned SDK no
/// longer resolves (that's the very condition the command exists to repair); environment
/// checks similarly just need the configured path so they can run their own targeted
/// inspections against it.
/// </remarks>
Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// As <see cref="GetAppHostFromSettingsAsync(CancellationToken)"/>, but rooted at a specific
/// directory.
/// </summary>
Task<FileInfo?> GetAppHostFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken = default)
=> GetAppHostFromSettingsAsync(cancellationToken);
}
internal sealed record AppHostProjectCandidate(FileInfo AppHostFile, string Language, AppHostProjectCandidateStatus Status = AppHostProjectCandidateStatus.Buildable);
internal enum AppHostProjectCandidateStatus
{
Buildable,
PossiblyUnbuildable
}
internal sealed class ProjectLocator(
ILogger<ProjectLocator> logger,
CliExecutionContext executionContext,
IEnvironment environment,
IInteractionService interactionService,
IConfigurationService configurationService,
IAppHostProjectFactory projectFactory,
ILanguageDiscovery languageDiscovery,
IDotNetSdkInstaller sdkInstaller,
IAppHostCandidateFinder appHostCandidateFinder,
AspireCliTelemetry telemetry,
IConfiguration configuration) : IProjectLocator
{
private const string AspireConfigAppHostPathKey = "appHost.path";
private const string LegacySettingsAppHostPathKey = "appHostPath";
private const string ExplicitLaunchConfigurationSelectionOrigin = "explicit-launch-configuration";
private const string ExplicitCliSelectionOrigin = "explicit-cli";
private static readonly TimeSpan s_workspaceConfigLockTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Finds all candidate AppHost projects in the specified search directory with language metadata.
/// </summary>
/// <param name="searchDirectory">The directory to search recursively.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost projects with language metadata sorted by full path.</returns>
public async Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(
DirectoryInfo searchDirectory,
AppHostDiscoveryScope scope,
CancellationToken cancellationToken)
{
return await FindAppHostProjectsAsync(searchDirectory, scope, maxDepth: null, cancellationToken: cancellationToken);
}
/// <summary>
/// Finds all candidate AppHost projects in the specified search directory with language metadata.
/// </summary>
/// <param name="searchDirectory">The directory to search.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="maxDepth">The maximum subdirectory depth to search, where 0 only considers files in <paramref name="searchDirectory"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost projects with language metadata sorted by full path.</returns>
public async Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, int? maxDepth, CancellationToken cancellationToken)
{
var allCandidates = await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, scope, maxDepth, cancellationToken: cancellationToken);
var candidates = allCandidates.BuildableAppHost.Concat(allCandidates.UnbuildableSuspectedAppHostProjects).ToList();
candidates.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal));
return candidates;
}
public async IAsyncEnumerable<AppHostProjectCandidate> FindAppHostProjectsStreamAsync(
DirectoryInfo searchDirectory,
AppHostDiscoveryScope scope,
Action<int>? onDirectoryEnumerated = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var channel = Channel.CreateUnbounded<AppHostProjectCandidate>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
using var discoveryCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var discoveryTask = CompleteFindAppHostProjectsStreamAsync(searchDirectory, scope, channel.Writer, onDirectoryEnumerated, discoveryCancellationTokenSource.Token);
try
{
await foreach (var candidate in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return candidate;
}
await discoveryTask.ConfigureAwait(false);
}
finally
{
if (!discoveryTask.IsCompleted)
{
discoveryCancellationTokenSource.Cancel();
}
try
{
await discoveryTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (discoveryCancellationTokenSource.IsCancellationRequested)
{
// Enumeration can stop before discovery finishes (for example Ctrl+C). In that case
// cancellation is already being surfaced to the consumer through ReadAllAsync.
}
}
}
private async Task CompleteFindAppHostProjectsStreamAsync(
DirectoryInfo searchDirectory,
AppHostDiscoveryScope scope,
ChannelWriter<AppHostProjectCandidate> candidateWriter,
Action<int>? onDirectoryEnumerated,
CancellationToken cancellationToken)
{
try
{
await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, scope, maxDepth: null, candidateWriter, onDirectoryEnumerated, cancellationToken).ConfigureAwait(false);
candidateWriter.TryComplete();
}
catch (Exception ex)
{
candidateWriter.TryComplete(ex);
}
}
/// <summary>
/// Finds all candidate AppHost project files in the specified search directory path.
/// </summary>
/// <param name="searchDirectory">The directory path to search recursively.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost project files sorted by full path.</returns>
public async Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
{
return await FindAppHostProjectFilesAsync(searchDirectory, scope, maxDepth: null, cancellationToken);
}
/// <summary>
/// Finds all candidate AppHost project files in the specified search directory path.
/// </summary>
/// <param name="searchDirectory">The directory path to search.</param>
/// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
/// <param name="maxDepth">The maximum subdirectory depth to search, where 0 only considers files in <paramref name="searchDirectory"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost project files sorted by full path.</returns>
public async Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, int? maxDepth, CancellationToken cancellationToken)
{
var candidates = await FindAppHostProjectsAsync(searchDirectory, scope, maxDepth, cancellationToken);
return candidates.Select(c => c.AppHostFile).ToList();
}
/// <summary>
/// Finds all candidate AppHost project files in the specified search directory.
/// </summary>
/// <param name="searchDirectory">The directory to search recursively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A list of candidate AppHost project files sorted by full path.</returns>
public async Task<List<FileInfo>> FindAppHostProjectFilesAsync(string searchDirectory, CancellationToken cancellationToken)
{
// Preserve this legacy overload's previous "find anywhere under this path"
// behavior. New command paths use the overload that requires an explicit
// AppHostDiscoveryScope so callers must choose git-aware/default filtering,
// explicit-directory filtering, or the legacy all-files walk deliberately.
return await FindAppHostProjectFilesAsync(new DirectoryInfo(searchDirectory), AppHostDiscoveryScope.AllFiles, cancellationToken);
}
private async Task<(List<AppHostProjectCandidate> BuildableAppHost, List<AppHostProjectCandidate> UnbuildableSuspectedAppHostProjects, List<FileInfo> UnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, bool displayProgress, AppHostDiscoveryScope scope, int? maxDepth, ChannelWriter<AppHostProjectCandidate>? candidateWriter = null, Action<int>? onDirectoryEnumerated = null, CancellationToken cancellationToken = default)
{
using var activity = telemetry.StartDiagnosticActivity();
async Task<(List<AppHostProjectCandidate> BuildableAppHost, List<AppHostProjectCandidate> UnbuildableSuspectedAppHostProjects, List<FileInfo> UnsupportedProjects)> FindAppHostsAsync()
{
var appHostProjects = new List<AppHostProjectCandidate>();
var unbuildableSuspectedAppHostProjects = new List<AppHostProjectCandidate>();
var unsupportedProjects = new List<FileInfo>();
var lockObject = new object();
logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName);
async ValueTask ReportCandidateFoundAsync(AppHostProjectCandidate appHostProject, CancellationToken cancellationToken)
{
if (candidateWriter is null)
{
return;
}
// Candidate validation runs in parallel, but consumers want one async stream they can
// await in command code. A channel bridges those parallel workers to IAsyncEnumerable<T>
// without letting terminal or JSON rendering re-enter state protected by lockObject.
await candidateWriter.WriteAsync(appHostProject, cancellationToken).ConfigureAwait(false);
}
using var validationCancellationTokenSource = stopAfterMultipleBuildableAppHosts
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: null;
var validationCancellationToken = validationCancellationTokenSource?.Token ?? cancellationToken;
var parallelOptions = new ParallelOptions
{
CancellationToken = validationCancellationToken,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
// Get detection patterns from all languages
var allLanguages = await languageDiscovery.GetAvailableLanguagesAsync(cancellationToken);
var allPatterns = allLanguages.SelectMany(l => l.DetectionPatterns).Distinct().ToArray();
logger.LogDebug("Searching for patterns: {Patterns}", string.Join(", ", allPatterns));
var nugetCachePath = GetNuGetPackagesCachePath();
logger.LogDebug("NuGet cache path to exclude: {NuGetCachePath}", nugetCachePath ?? "(none)");
// Collect all candidates with their handlers across all patterns.
var candidatesWithHandlers = new List<(FileInfo File, IAppHostProject Handler)>();
var candidateSearchResult = await appHostCandidateFinder.FindCandidateFilesAsync(searchDirectory, allPatterns, nugetCachePath, scope, cancellationToken, maxDepth, onDirectoryEnumerated);
var candidateFiles = candidateSearchResult.Files;
var candidateCountsByPattern = candidateSearchResult.CountsByPattern;
foreach (var pattern in allPatterns)
{
logger.LogDebug("Found {CandidateCount} files matching pattern '{Pattern}'", candidateCountsByPattern[pattern], pattern);
}
logger.LogDebug("Found {CandidateCount} unique candidate files matching AppHost detection patterns", candidateFiles.Length);
foreach (var candidateFile in candidateFiles)
{
logger.LogDebug("Checking candidate file {CandidateFile}", candidateFile.FullName);
var handler = projectFactory.TryGetProject(candidateFile);
if (handler is null)
{
logger.LogTrace("No handler found for {CandidateFile}", candidateFile.FullName);
continue;
}
candidatesWithHandlers.Add((candidateFile, handler));
}
// If any candidates are .NET projects, ensure the SDK is available
var dotNetCandidate = candidatesWithHandlers.FirstOrDefault(c => c.Handler.LanguageId.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase));
if (dotNetCandidate.Handler is { } dotNetHandler)
{
// TODO: Consider moving this check inside the handler.
// Would need to support caching and reusing check across validations.
if (!await SdkInstallHelper.EnsureSdkInstalledAsync(sdkInstaller, interactionService, telemetry, displayError: displayProgress, cancellationToken: cancellationToken))
{
if (!displayProgress)
{
interactionService.DisplayRawText(ErrorStrings.DotNetSdkUnavailableAppHostDiscoveryWarning, ConsoleOutput.Error);
}
logger.LogWarning("The .NET SDK is not available. Marking .NET projects as unsupported.");
dotNetHandler.IsUnsupported = true;
}
}
try
{
await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (candidate, ct) =>
{
var (candidateFile, handler) = candidate;
// Validate the candidate file using the handler
var validationResult = await handler.ValidateAppHostAsync(candidateFile, ct);
if (validationResult.IsValid)
{
logger.LogDebug("Found {Language} apphost {CandidateFile}", handler.DisplayName, candidateFile.FullName);
var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName);
AppHostProjectCandidate appHostProject;
if (displayProgress)
{
interactionService.DisplaySubtleMessage(relativePath);
}
lock (lockObject)
{
appHostProject = new AppHostProjectCandidate(candidateFile, handler.LanguageId);
appHostProjects.Add(appHostProject);
if (stopAfterMultipleBuildableAppHosts && appHostProjects.Count >= 2)
{
validationCancellationTokenSource?.Cancel();
}
}
await ReportCandidateFoundAsync(appHostProject, ct).ConfigureAwait(false);
}
else if (validationResult.IsUnsupported)
{
var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName);
if (displayProgress)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath));
}
logger.LogDebug("Skipping unsupported project {CandidateFile}", candidateFile.FullName);
lock (lockObject)
{
unsupportedProjects.Add(candidateFile);
}
}
else if (validationResult.IsPossiblyUnbuildable)
{
var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName);
AppHostProjectCandidate appHostProject;
if (displayProgress)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, relativePath));
}
lock (lockObject)
{
appHostProject = new AppHostProjectCandidate(candidateFile, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable);
unbuildableSuspectedAppHostProjects.Add(appHostProject);
}
await ReportCandidateFoundAsync(appHostProject, ct).ConfigureAwait(false);
}
else
{
logger.LogTrace("File {CandidateFile} is not a valid Aspire host", candidateFile.FullName);
}
});
}
catch (OperationCanceledException) when (validationCancellationTokenSource?.IsCancellationRequested is true && !cancellationToken.IsCancellationRequested)
{
logger.LogDebug("Stopping AppHost discovery early after finding multiple valid AppHost projects.");
}
// Explicit-directory callers asked to inspect only the named subtree. Importing an
// AppHost from a parent aspire.config.json violates that boundary and can affect both
// selection and shallow probes such as `aspire doctor`.
if (scope is not AppHostDiscoveryScope.ExplicitDirectory)
{
await AddSettingsAppHostCandidateAsync().ConfigureAwait(false);
}
// This sort is done here to make results deterministic since we get all the app
// host information in parallel and the order may vary.
appHostProjects.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal));
return (appHostProjects, unbuildableSuspectedAppHostProjects, unsupportedProjects);
async Task AddSettingsAppHostCandidateAsync()
{
var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: false, cancellationToken).ConfigureAwait(false);
if (settingsAppHost is null)
{
return;
}
// Windows and default macOS APFS volumes are case-insensitive, so a
// differently-cased settings path can still refer to the same file found
// by the discovery walk. See https://github.com/microsoft/aspire/issues/17635.
var pathComparison = environment.IsWindows() || environment.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
// Canonicalize symlinks before comparing so a settings-derived candidate
// like /tmp/L5/x.cs does not produce a duplicate entry next to the
// discovery-walked /private/tmp/L5/x.cs on macOS, where /tmp is a symlink
// to /private/tmp. See https://github.com/microsoft/aspire/issues/17626.
// Resolved paths are used as comparison keys only — the surfaced
// AppHostProjectCandidate keeps the original FileInfo so display paths are
// unchanged from what the user-authored settings file pointed at.
//
// Symlink resolution does ~one syscall per path segment, so we keep it
// off the hot path: the exact-string compare below short-circuits before
// the per-candidate resolve runs at all in the common case (no symlinks
// involved). Pre-materializing canonical paths for every candidate would
// force the resolve even when the cheap compare would have matched.
var settingsCanonicalPath = PathNormalizer.ResolveSymlinks(settingsAppHost.FullName);
bool IsDuplicate(AppHostProjectCandidate candidate)
{
if (string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))
{
return true;
}
var candidateCanonicalPath = PathNormalizer.ResolveSymlinks(candidate.AppHostFile.FullName);
return string.Equals(candidateCanonicalPath, settingsCanonicalPath, pathComparison);
}
if (appHostProjects.Any(IsDuplicate) || unbuildableSuspectedAppHostProjects.Any(IsDuplicate))
{
return;
}
var handler = projectFactory.TryGetProject(settingsAppHost);
if (handler is null)
{
var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName);
if (displayProgress)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath));
}
logger.LogDebug("Skipping configured AppHost project {SettingsAppHost} because no project handler was found.", settingsAppHost.FullName);
unsupportedProjects.Add(settingsAppHost);
return;
}
var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken).ConfigureAwait(false);
var settingsAppHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName);
if (validationResult.IsValid)
{
if (displayProgress)
{
interactionService.DisplaySubtleMessage(settingsAppHostRelativePath);
}
var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId);
appHostProjects.Add(appHostProject);
await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false);
}
else if (validationResult.IsPossiblyUnbuildable)
{
if (displayProgress)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, settingsAppHostRelativePath));
}
var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable);
unbuildableSuspectedAppHostProjects.Add(appHostProject);
await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false);
}
else if (validationResult.IsUnsupported)
{
if (displayProgress)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, settingsAppHostRelativePath));
}
logger.LogDebug("Skipping unsupported configured AppHost project {SettingsAppHost}", settingsAppHost.FullName);
unsupportedProjects.Add(settingsAppHost);
}
}
}
if (displayProgress)
{
return await interactionService.ShowStatusAsync(InteractionServiceStrings.FindingAppHosts, FindAppHostsAsync);
}
return await FindAppHostsAsync();
}
/// <inheritdoc />
public async Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default)
{
return await GetAppHostFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken);
}
/// <inheritdoc />
public async Task<FileInfo?> GetAppHostFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken = default)
{
// Intentionally does not call ValidateAppHostAsync. See interface XML docs for rationale.
// Probe-style callers (DotNetSdkCheck, AspireVersionCheck, TypeScriptAppHostToolingCheck,
// UpdateCommand, IntegrationPackageSearchService) drive this path and expect a
// non-interactive answer; the user-facing legacy-migration warning is emitted from the
// discovery walk (AddSettingsAppHostCandidateAsync) instead.
var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken);
if (settingsAppHost is null)
{
return null;
}
var handler = projectFactory.TryGetProject(settingsAppHost);
if (handler is null)
{
logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because no project handler can process it.", settingsAppHost.FullName);
return null;
}
return settingsAppHost;
}
/// <summary>
/// The AppHost resolved from <c>aspire.config.json</c> (or migrated legacy settings), if any.
/// </summary>
/// <param name="AppHost">The configured AppHost, or <see langword="null"/> when none was usable.</param>
/// <param name="IsUnverified">
/// <see langword="true"/> when MSBuild could not evaluate the configured AppHost, so it could not be
/// confirmed to be an AppHost. The selection is still honored, but it must never be persisted back to
/// settings and callers are expected to surface the underlying build diagnostics.
/// </param>
private readonly record struct SettingsAppHostResult(FileInfo? AppHost, bool IsUnverified);
/// <summary>
/// Determines whether <paramref name="file"/> lives beneath <paramref name="directory"/>.
/// </summary>
/// <remarks>
/// Windows paths are compared case-insensitively. Other platforms use case-sensitive comparison
/// because macOS can use case-sensitive APFS volumes.
/// </remarks>
internal static bool IsUnderDirectory(FileInfo file, DirectoryInfo directory, IEnvironment environment)
{
var pathComparison = environment.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
// Compare the raw paths first. The discovery walk can reach a candidate by descending through
// a symlinked subdirectory, and canonicalizing that path would relocate it outside the
// directory the user actually named.
if (IsUnder(file.FullName, directory.FullName))
{
return true;
}
// Otherwise canonicalize both sides, because the same directory can be spelled two ways: on
// macOS /tmp is a symlink to /private/tmp, so a candidate discovered as /private/tmp/x/App.csproj
// would not textually start with /tmp/x. See https://github.com/microsoft/aspire/issues/17626.
return IsUnder(PathNormalizer.ResolveSymlinks(file.FullName), PathNormalizer.ResolveSymlinks(directory.FullName));
bool IsUnder(string filePath, string directoryPath)
{
// The trailing separator keeps a sibling with a shared name prefix (".../Services2")
// from matching ".../Services".
var prefix = directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return filePath.StartsWith(prefix, pathComparison);
}
}
private async Task<SettingsAppHostResult> GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken)
{
// This is reached from UseOrFindAppHostProjectFileAsync. When the configured
// legacy settings point at a missing file we still want the warning to surface,
// but the discovery walk that runs afterwards (AddSettingsAppHostCandidateAsync)
// will emit the same warning. Stay silent here to avoid a duplicate.
var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken);
if (settingsAppHost is null)
{
return default;
}
var handler = projectFactory.TryGetProject(settingsAppHost);
if (handler is null)
{
logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because no project handler can process it.", settingsAppHost.FullName);
return default;
}
var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken);
if (validationResult.IsValid)
{
return new SettingsAppHostResult(settingsAppHost, IsUnverified: false);
}
var messageSuffix = validationResult.Message is { Length: > 0 } message ? $": {message}" : string.Empty;
if (validationResult.IsUnsupported)
{
logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because it is not supported in the current environment{MessageSuffix}.", settingsAppHost.FullName, messageSuffix);
}
else if (validationResult.IsPossiblyUnbuildable)
{
// A configured AppHost is as deliberate a choice as --apphost, so keep it rather than
// falling back to discovery. Discarding it reported "No AppHosts were found ..." for a path
// the CLI had already resolved, and could silently run a different application that
// discovery happened to find. See https://github.com/microsoft/aspire/issues/19035.
logger.LogWarning("AppHost path '{AppHostPath}' from settings could not be evaluated by MSBuild and may not be buildable{MessageSuffix}.", settingsAppHost.FullName, messageSuffix);
return new SettingsAppHostResult(settingsAppHost, IsUnverified: true);
}
else
{
logger.LogWarning("Ignoring AppHost path '{AppHostPath}' from settings because it is no longer a valid AppHost project{MessageSuffix}.", settingsAppHost.FullName, messageSuffix);
}
return default;
}
private async Task<FileInfo?> GetAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, bool silent, CancellationToken cancellationToken)
{
while (true)
{
// Check aspire.config.json first
AspireConfigFile? aspireConfig;
try
{
aspireConfig = AspireConfigFile.Load(searchDirectory.FullName);
}
catch (JsonException ex)
{
ReportInvalidConfigurationFile(ex, ex.Message, silent);
return null;
}
if (aspireConfig?.AppHost?.Path is { } configAppHostPath)
{
var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName);
// Validate before Path.Combine / new FileInfo, which throw ArgumentException
// ("Null character in path." / "Illegal characters in path.") on NUL bytes and
// other invalid characters that survive JSON parsing. Without this we surface
// as a generic "An unexpected error occurred" — see
// https://github.com/microsoft/aspire/issues/17624.
if (!IsValidConfiguredAppHostPath(configAppHostPath, configFilePath, fieldName: AspireConfigAppHostPathKey, silent: silent))
{
return null;
}
var qualifiedPath = Path.IsPathRooted(configAppHostPath)
? configAppHostPath
: Path.Combine(searchDirectory.FullName, configAppHostPath);
qualifiedPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedPath);
var appHostFile = new FileInfo(qualifiedPath);
if (appHostFile.Exists)
{
logger.LogInformation("Found AppHost path '{AppHostPath}' from config file in {Directory}", configAppHostPath, searchDirectory.FullName);
return appHostFile;
}
else
{
if (!silent)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, configFilePath, qualifiedPath));
}
return null;
}
}
// TODO: Remove legacy .aspire/settings.json fallback once confident most users have migrated.
// Tracked by https://github.com/microsoft/aspire/issues/15239
// Fall back to .aspire/settings.json
var settingsFile = new FileInfo(ConfigurationHelper.BuildPathToSettingsJsonFile(searchDirectory.FullName));
if (settingsFile.Exists)
{
try
{
using var stream = settingsFile.OpenRead();
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (json.RootElement.ValueKind is not JsonValueKind.Object)
{
ReportInvalidConfigurationFileShape(settingsFile.FullName, silent);
return null;
}
if (json.RootElement.TryGetProperty(LegacySettingsAppHostPathKey, out var appHostPathProperty))
{
if (appHostPathProperty.ValueKind is not JsonValueKind.Null and not JsonValueKind.String)
{
ReportInvalidConfiguredAppHostPathType(settingsFile.FullName, LegacySettingsAppHostPathKey, silent);
return null;
}
if (appHostPathProperty.GetString() is { } appHostPath)
{
// Mirror the validation on the modern path above so the legacy branch also
// cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars
// value (https://github.com/microsoft/aspire/issues/17624).
if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, fieldName: LegacySettingsAppHostPathKey, silent: silent))
{
return null;
}
var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath);
qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath);
var appHostFile = new FileInfo(qualifiedAppHostPath);
if (appHostFile.Exists)
{
return appHostFile;
}
else
{
if (!silent)
{
// Warn against the user-authored file (.aspire/settings.json), not the
// never-authored aspire.config.json. Earlier versions reported
// aspire.config.json because startup eagerly migrated the legacy
// settings (PR #17234); see https://github.com/microsoft/aspire/issues/17620
// for the user-facing impact of pointing users at a file they did
// not create.
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath));
}
return null;
}
}
}
}
catch (JsonException ex)
{
var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, settingsFile.FullName, ex.Message);
ReportInvalidConfigurationFile(ex, message, silent);
return null;
}
}
if (searchParentDirectories && searchDirectory.Parent is not null)
{
searchDirectory = searchDirectory.Parent;
}
else
{
return null;
}
}
}
private void ReportInvalidConfigurationFileShape(string configFilePath, bool silent)
{
var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfigurationFileMustBeJsonObject, configFilePath);
if (!silent)
{
interactionService.DisplayError(message);
}
else
{
logger.LogWarning("Ignoring AppHost settings in '{ConfigFilePath}' because the configuration root is not a JSON object.", configFilePath);
}
}
private void ReportInvalidConfiguredAppHostPathType(string configFilePath, string fieldName, bool silent)
{
var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathMustBeString, configFilePath, fieldName);
if (!silent)
{
interactionService.DisplayError(message);
}
else
{
logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is not a JSON string.", configFilePath, fieldName);
}
}
private void ReportInvalidConfigurationFile(JsonException ex, string message, bool silent)
{
if (!silent)
{
interactionService.DisplayError(message);
}
else
{
logger.LogWarning(ex, "Unable to load AppHost settings: {Message}", message);
}
}
// Reject empty paths (Path.Combine("", base) collapses to the base directory and surfaces
// a misleading "directory doesn't exist" warning downstream) and paths that contain
// characters that would crash System.IO APIs. Path.GetInvalidPathChars() includes NUL on
// every platform plus the platform-specific set of disallowed characters (e.g. < > | on
// Windows). Plain Contains('\0') is included explicitly for readability even though it is
// redundant with the IndexOfAny check.
private bool IsValidConfiguredAppHostPath(string path, string configFilePath, string fieldName, bool silent)
{
if (path.Length == 0 || path.Contains('\0') || path.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
{
if (!silent)
{
interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathHasInvalidCharacters, configFilePath, fieldName));
}
else
{
logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is empty or contains invalid characters.", configFilePath, fieldName);
}
return false;
}
return true;
}
public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default)
{
return UseOrFindAppHostProjectFileAsync(projectFile, multipleAppHostProjectsFoundBehavior, createSettingsFile, displayProgress: true, cancellationToken);
}
public async Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, bool displayProgress, CancellationToken cancellationToken = default)
{
logger.LogDebug("Finding project file in {CurrentDirectory}", executionContext.WorkingDirectory);
var explicitSelectionWasPrompted = false;
if (projectFile is not null)
{
// Check if the provided path is actually a directory
if (Directory.Exists(projectFile.FullName))
{
logger.LogDebug("Provided path {Path} is a directory, searching for project files recursively", projectFile.FullName);
var directory = new DirectoryInfo(projectFile.FullName);
// The user explicitly pointed at this directory, so don't let gitignore
// hide AppHosts under it. Still apply the built-in junk-directory skip
// list for dependency/build-output folders.
var searchResults = await FindAppHostProjectFilesAsync(
directory,
stopAfterMultipleBuildableAppHosts: multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Throw,
displayProgress: displayProgress,
scope: AppHostDiscoveryScope.ExplicitDirectory,
maxDepth: null,
cancellationToken: cancellationToken);
// Keep the requested directory as the selection boundary even if additional
// explicit-discovery candidate sources are introduced later.
var appHostProjects = searchResults.BuildableAppHost
.Where(c => IsUnderDirectory(c.AppHostFile, directory, environment))
.Select(c => c.AppHostFile)
.ToList();
if (displayProgress)
{
interactionService.DisplayEmptyLine();
}
if (appHostProjects.Count == 0)
{
var unbuildableInDirectory = searchResults.UnbuildableSuspectedAppHostProjects
.Where(c => IsUnderDirectory(c.AppHostFile, directory, environment))
.ToList();
// The user pointed at this directory, and it holds exactly one candidate that only
// failed because MSBuild could not evaluate it. Selecting it is the same intent as
// naming the file, and it lets the caller's build surface the real MSBuild
// diagnostics instead of a resolution error that hides them. See
// https://github.com/microsoft/aspire/issues/19035.
if (unbuildableInDirectory.Count == 1)
{
var unbuildableAppHost = unbuildableInDirectory[0].AppHostFile;
logger.LogDebug(
"Selecting AppHost project file {ProjectFile} in directory {Directory} even though MSBuild could not evaluate it.",
unbuildableAppHost.FullName,
directory.FullName);
// Deliberately skip CreateSettingsFileAsync: this candidate was never confirmed to
// be an AppHost, so persisting it would make later ambient invocations silently
// reuse an unverified guess.
return new AppHostProjectSearchResult(unbuildableAppHost, [unbuildableAppHost]);
}
if (unbuildableInDirectory.Count > 1)
{
// Several broken candidates under one directory is a genuine ambiguity rather than
// a user selection, so this stays a project-resolution failure.
throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable);
}
if (searchResults.UnsupportedProjects.Any(file => IsUnderDirectory(file, directory, environment)))
{
throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.UnsupportedProjects);
}
logger.LogError("No AppHost project files found in directory {Directory}", directory.FullName);
throw new ProjectLocatorException(ErrorStrings.ProjectFileDoesntExist, ProjectLocatorFailureReason.ProjectFileDoesntExist);
}
else if (appHostProjects.Count == 1)
{
logger.LogDebug("Found single AppHost project file {ProjectFile} in directory {Directory}", appHostProjects[0].FullName, directory.FullName);
projectFile = appHostProjects[0];
}
else
{
if (multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Prompt)
{
logger.LogDebug("Multiple AppHost project files found in directory {Directory}, prompting user to select", directory.FullName);
explicitSelectionWasPrompted = true;
projectFile = await interactionService.PromptForSelectionAsync(
InteractionServiceStrings.SelectAppHostToUse,
appHostProjects,
file => $"{file.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName).EscapeMarkup()})",
cancellationToken: cancellationToken
);
}
else if (multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.None)
{
logger.LogDebug("Multiple AppHost project files found in directory {Directory}, selecting none", directory.FullName);
return new AppHostProjectSearchResult(null, appHostProjects);
}
else if (multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Throw)
{
logger.LogError("Multiple AppHost project files found in directory {Directory}, throwing exception", directory.FullName);
throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound, ProjectLocatorFailureReason.MultipleProjectFilesFound);
}
}
}
else if (File.Exists(projectFile.FullName))
{
// A project file was directly specified.
//
// Resolve to the filesystem-canonical path so the path used for backchannel socket
// hash computation matches.
var resolvedProjectPath = PathNormalizer.ResolveToFilesystemPath(projectFile.FullName);
if (!string.Equals(resolvedProjectPath, projectFile.FullName, StringComparison.Ordinal))
{
logger.LogDebug(
"Canonicalized explicit AppHost path from '{OriginalPath}' to '{ResolvedPath}'.",
projectFile.FullName,
resolvedProjectPath);
projectFile = new FileInfo(resolvedProjectPath);
}
}
if (projectFile is not null)
{
// If the project file is passed, validate it.
if (!projectFile.Exists)
{
logger.LogError("Project file {ProjectFile} does not exist.", projectFile.FullName);
throw new ProjectLocatorException(ErrorStrings.ProjectFileDoesntExist, ProjectLocatorFailureReason.ProjectFileDoesntExist);
}
// Check if any handler can handle this file
var handler = projectFactory.TryGetProject(projectFile);
if (handler is not null)
{
// The handler still may have matched an invalid single file apphost, so validate it before accepting as the selected project file
var validationResult = await handler.ValidateAppHostAsync(projectFile, cancellationToken);
if (validationResult.IsValid)
{
logger.LogDebug("Using {Language} apphost {ProjectFile}", handler.DisplayName, projectFile.FullName);
if (createSettingsFile)
{
await CreateSettingsFileAsync(projectFile, preserveExistingDefault: !explicitSelectionWasPrompted, cancellationToken);
}
return new AppHostProjectSearchResult(projectFile, [projectFile])
{
WasExplicitDirectorySelectionPrompted = explicitSelectionWasPrompted
};
}
if (validationResult.IsPossiblyUnbuildable)
{
// The user named this exact file and it does exist. MSBuild simply could not
// evaluate it (unresolvable Aspire.AppHost.Sdk, malformed XML, ...), so keep it
// selected and let the caller's build print the real MSB4236/CS diagnostics.
// Reporting a resolution failure here produced the misleading "the --apphost
// option specified a project that does not exist" in
// https://github.com/microsoft/aspire/issues/19035.
logger.LogDebug(
"Selecting explicitly specified AppHost {ProjectFile} even though MSBuild could not evaluate it.",
projectFile.FullName);
// Deliberately skip CreateSettingsFileAsync: see the explicit-directory path above.
return new AppHostProjectSearchResult(projectFile, [projectFile]);
}
}
// If no handler matched, for .cs files check if we should search the parent directory
if (projectFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase) && projectFile.Directory is { } parentDirectory)
{
// File exists but is not a valid single-file apphost. Search in the parent directory.
// Propagate displayProgress so callers that opted out of progress UI (e.g. the hidden
// `extension get-apphosts` flow) do not start emitting progress on this fallback path.
return await UseOrFindAppHostProjectFileAsync(new FileInfo(parentDirectory.FullName), multipleAppHostProjectsFoundBehavior, createSettingsFile, displayProgress, cancellationToken);
}
// No handler can process this file
throw new ProjectLocatorException(ErrorStrings.ProjectFileDoesntExist, ProjectLocatorFailureReason.ProjectFileDoesntExist);
}
}
var settingsResult = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken);
var settingsAppHost = settingsResult.AppHost;
if (settingsAppHost is not null && multipleAppHostProjectsFoundBehavior is not MultipleAppHostProjectsFoundBehavior.None)
{
logger.LogDebug("Using AppHost path from settings without scanning: {AppHost}", settingsAppHost.FullName);
// An unverified selection is never persisted: rewriting settings would turn a candidate that
// was only kept because MSBuild failed into a confirmed choice.
if (createSettingsFile && !settingsResult.IsUnverified)
{
await CreateSettingsFileAsync(settingsAppHost, preserveExistingDefault: false, cancellationToken);
}
return new AppHostProjectSearchResult(settingsAppHost, [settingsAppHost]);
}
logger.LogDebug("No project file specified, searching for apphost projects in {CurrentDirectory}", executionContext.WorkingDirectory);
// No --project was provided; this is ambient discovery from the working
// directory, so use git-aware/default filters.
var results = await FindAppHostProjectFilesAsync(
executionContext.WorkingDirectory,
stopAfterMultipleBuildableAppHosts: multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Throw && settingsAppHost is null,
displayProgress: displayProgress,
scope: AppHostDiscoveryScope.DefaultFiltered,
maxDepth: null,
cancellationToken: cancellationToken);
logger.LogDebug("Found {ProjectFileCount} project files.", results.BuildableAppHost.Count);
FileInfo? selectedAppHost = null;
if (results.BuildableAppHost.Count == 0 && results.UnbuildableSuspectedAppHostProjects.Count == 0)
{
if (settingsAppHost is not null)
{
selectedAppHost = settingsAppHost;
}
else if (results.UnsupportedProjects.Count > 0)
{
throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.UnsupportedProjects);
}
else
{
throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound);
}
}
else if (results.BuildableAppHost.Count == 0 && results.UnbuildableSuspectedAppHostProjects.Count > 0)
{
if (settingsAppHost is not null)
{
selectedAppHost = settingsAppHost;
}
else
{
throw new ProjectLocatorException(ErrorStrings.AppHostsMayNotBeBuildable, ProjectLocatorFailureReason.AppHostsMayNotBeBuildable);
}
}
else if (results.BuildableAppHost.Count == 1)
{
selectedAppHost = settingsAppHost ?? results.BuildableAppHost[0].AppHostFile;
}
else if (results.BuildableAppHost.Count > 1)
{
// Check if a previously-selected apphost is cached in settings and
// is still among the discovered candidates. If so, reuse it to avoid
// prompting the user every time when nothing has changed.
var pathComparison = environment.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (settingsAppHost is not null
&& (settingsResult.IsUnverified
|| results.BuildableAppHost.Any(c => string.Equals(c.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))))
{
// An unverified configured AppHost can never appear in BuildableAppHost by
// construction, but it is still an explicit user choice. Honoring it here keeps this
// branch consistent with the single-candidate branch above, which already prefers the
// configured AppHost, and lets the caller's build surface the real MSBuild error
// instead of prompting for (or silently running) a different application.
logger.LogDebug("Using previously-selected AppHost from settings: {AppHost}", settingsAppHost.FullName);
selectedAppHost = settingsAppHost;
}
else
{
// No valid cached selection — prompt or error based on interactivity.
selectedAppHost = multipleAppHostProjectsFoundBehavior switch
{
MultipleAppHostProjectsFoundBehavior.Throw => throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound, ProjectLocatorFailureReason.MultipleProjectFilesFound),
MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost.Select(c => c.AppHostFile).ToList(), projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken: cancellationToken),
MultipleAppHostProjectsFoundBehavior.None => null,
_ => selectedAppHost
};
}
}
// A selection that came from unverified settings must not be persisted (see the early-return
// above); this path is reached when MultipleAppHostProjectsFoundBehavior.None skipped it.
var selectionIsUnverifiedSettingsAppHost = settingsResult.IsUnverified
&& selectedAppHost is not null
&& string.Equals(selectedAppHost.FullName, settingsAppHost?.FullName, environment.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
if (createSettingsFile && !selectionIsUnverifiedSettingsAppHost)
{
await CreateSettingsFileAsync(selectedAppHost!, preserveExistingDefault: false, cancellationToken);
}
// Ensure the selected AppHost is always represented in the candidate list so callers
// can rely on SelectedProjectFile being present in AllProjectFileCandidates. This
// covers cases where the configured settings AppHost is selected but lives outside
// the discovered candidate set (e.g. parent directory or excluded by enumeration).
var allCandidates = results.BuildableAppHost.Select(c => c.AppHostFile).ToList();
if (selectedAppHost is not null
&& !allCandidates.Any(f => string.Equals(f.FullName, selectedAppHost.FullName, environment.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)))
{
allCandidates = [.. allCandidates, selectedAppHost];
}
return new AppHostProjectSearchResult(selectedAppHost, allCandidates);
}
public async Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken = default)
{
var result = await UseOrFindAppHostProjectFileAsync(projectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile, cancellationToken);
return result.SelectedProjectFile;
}
/// <summary>
/// Determines whether a persisted AppHost path identifies the selected project on the current platform.
/// </summary>
internal static bool IsSamePersistedAppHostPath(string persistedPath, string selectedPath, IEnvironment environment)
{
var pathComparison = environment.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(persistedPath, selectedPath, pathComparison);
}
private async Task CreateSettingsFileAsync(FileInfo projectFile, bool preserveExistingDefault, CancellationToken cancellationToken)
{
var configuredSelectionOrigin = configuration[KnownConfigNames.CliAppHostSelectionOrigin];
var isExplicitLaunchConfigurationSelection = string.Equals(
configuredSelectionOrigin,
ExplicitLaunchConfigurationSelectionOrigin,
StringComparison.OrdinalIgnoreCase);
var isExplicitCliSelection = string.Equals(
configuredSelectionOrigin,
ExplicitCliSelectionOrigin,
StringComparison.OrdinalIgnoreCase);
var hasConfiguredSelectionOrigin = !string.IsNullOrEmpty(configuredSelectionOrigin);
var selectionOrigin = hasConfiguredSelectionOrigin ? configuredSelectionOrigin : "--apphost";
var shouldPreserveExistingDefault =
isExplicitLaunchConfigurationSelection ||
(preserveExistingDefault && (isExplicitCliSelection || !hasConfiguredSelectionOrigin));
var (settingsFile, appHostDirForScopedConfig) = ResolveWorkspaceConfigTarget(projectFile);
// Compound launch configurations start multiple CLI processes together. The default check
// and the whole-file writes must share one cross-process critical section so only the first
// launch can establish a missing workspace default.
using var configLock = await TryAcquireWorkspaceConfigLockAsync(settingsFile, cancellationToken);
var existingConfig = LoadOrMigrateWorkspaceConfig(settingsFile);
var fileExisted = settingsFile.Exists;
if (existingConfig?.AppHost?.Path is { } existingPath &&
IsValidConfiguredAppHostPath(existingPath, settingsFile.FullName, AspireConfigAppHostPathKey, silent: true))
{
var resolvedPath = PathNormalizer.NormalizePathForCurrentPlatform(
Path.IsPathRooted(existingPath) ? existingPath : Path.Combine(settingsFile.Directory!.FullName, existingPath));
if (IsSamePersistedAppHostPath(resolvedPath, projectFile.FullName, environment))
{
logger.LogDebug(
"Config at {Path} already references apphost {AppHost}, skipping creation",
settingsFile.FullName,
projectFile.FullName);
return;
}
// An explicit CLI target or launch configuration is for this invocation only. Preserve
// an existing workspace default, but let the selected AppHost replace a deleted target.
if (shouldPreserveExistingDefault && File.Exists(resolvedPath))
{
logger.LogDebug(
"Not replacing recorded AppHost default {RecordedAppHost} with {AppHost} because the latter was selected by {SelectionOrigin}.",
resolvedPath,
projectFile.FullName,
selectionOrigin);
return;
}
}
logger.LogDebug("Creating settings file at {SettingsFilePath}", settingsFile.FullName);
var relativePathToProjectFile = Path.GetRelativePath(settingsFile.Directory!.FullName, projectFile.FullName).Replace(Path.DirectorySeparatorChar, '/');
// Use the configuration writer to set the AppHost path, which will merge with any existing settings.
await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, AspireConfigAppHostPathKey, relativePathToProjectFile, cancellationToken);
// For polyglot projects, also set language and inherit SDK version from parent/global config.
var language = languageDiscovery.GetLanguageByFile(projectFile);
if (language is not null && !language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase))
{
await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, "appHost.language", language.LanguageId.Value, cancellationToken);
// Inherit SDK version from parent/global config if available.
var inheritedSdkVersion = appHostDirForScopedConfig is not null
? await configurationService.GetConfigurationFromDirectoryAsync("sdk.version", appHostDirForScopedConfig, continueSearchWhenKeyMissing: true, cancellationToken: cancellationToken)
?? await configurationService.GetConfigurationFromDirectoryAsync("sdkVersion", appHostDirForScopedConfig, continueSearchWhenKeyMissing: true, cancellationToken: cancellationToken)
: await configurationService.GetConfigurationAsync("sdk.version", cancellationToken)
?? await configurationService.GetConfigurationAsync("sdkVersion", cancellationToken);
if (!string.IsNullOrEmpty(inheritedSdkVersion))
{
await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, "sdk.version", inheritedSdkVersion, cancellationToken);
logger.LogDebug("Set SDK version {Version} in settings file (inherited from parent config)", inheritedSdkVersion);
}
}
var relativeSettingsFilePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsFile.FullName).Replace(Path.DirectorySeparatorChar, '/');
var message = fileExisted ? InteractionServiceStrings.UpdatedSettingsFile : InteractionServiceStrings.CreatedSettingsFile;
interactionService.DisplayMessage(KnownEmojis.FloppyDisk, string.Format(CultureInfo.CurrentCulture, message, $"[bold]'{relativeSettingsFilePath.EscapeMarkup()}'[/]"), allowMarkup: true);
}
private (FileInfo SettingsFile, DirectoryInfo? AppHostDirectoryForScopedConfig) ResolveWorkspaceConfigTarget(FileInfo projectFile)
{
// Search from the AppHost's directory first so a config beside the AppHost wins over one
// associated with the working directory.
if (projectFile.Directory is { } appHostDirectory &&
ConfigurationHelper.FindNearestConfigFilePath(appHostDirectory) is { } configPath)
{
var configDirectoryPath = Path.GetDirectoryName(configPath)!;
var targetSettingsFilePath = configPath;
// For legacy .aspire/settings.json, the config root is the parent of .aspire/.
var trimmedConfigDirectoryPath = configDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(Path.GetFileName(trimmedConfigDirectoryPath), ".aspire", StringComparison.OrdinalIgnoreCase) &&
Directory.GetParent(trimmedConfigDirectoryPath) is { } parentDirectory)
{
targetSettingsFilePath = Path.Combine(parentDirectory.FullName, AspireConfigFile.FileName);
}
return (new FileInfo(targetSettingsFilePath), appHostDirectory);
}
var configuredSettingsFile = new FileInfo(configurationService.GetSettingsFilePath(isGlobal: false));
if (string.Equals(configuredSettingsFile.Name, AspireConfigFile.FileName, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Using existing config file at {Path}", configuredSettingsFile.FullName);
return (configuredSettingsFile, null);
}
var configRoot = ConfigurationHelper.GetLegacySettingsRootDirectory(configuredSettingsFile)
?? executionContext.WorkingDirectory;
var newConfigPath = Path.Combine(configRoot.FullName, AspireConfigFile.FileName);
logger.LogDebug("Will use workspace config at {Path}", newConfigPath);
return (new FileInfo(newConfigPath), null);
}
private AspireConfigFile? LoadOrMigrateWorkspaceConfig(FileInfo settingsFile)
{
var configRoot = settingsFile.Directory!;
if (settingsFile.Exists)
{
return AspireConfigFile.Load(configRoot.FullName);
}
var legacySettingsFile = new FileInfo(ConfigurationHelper.BuildPathToSettingsJsonFile(configRoot.FullName));
if (!legacySettingsFile.Exists)
{
return null;
}
logger.LogInformation("Migrating legacy settings from {LegacyDir} to {ConfigFile}", configRoot.FullName, settingsFile.FullName);
return AspireConfigFile.LoadOrCreate(configRoot.FullName);
}
private async Task<FileLock?> TryAcquireWorkspaceConfigLockAsync(FileInfo settingsFile, CancellationToken cancellationToken)
{
var lockPath = GetWorkspaceConfigLockPath(settingsFile);
try
{
return await FileLock.AcquireAsync(lockPath, cancellationToken, s_workspaceConfigLockTimeout);
}
catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException)
{
// Persisting the workspace default is bookkeeping around the requested command. Preserve
// the previous best-effort behavior if the cache directory cannot host the lock.
logger.LogDebug(ex, "Proceeding without the workspace config lock at {LockPath}.", lockPath);
return null;
}
}
private string GetWorkspaceConfigLockPath(FileInfo settingsFile)
{
// Lock files live in the cache so read-only workspaces remain usable. Canonicalizing and
// folding the config path makes aliases on symlinked or case-insensitive volumes contend;
// on a case-sensitive volume this can only serialize two otherwise independent writes.
var normalizedSettingsPath = PathNormalizer.ResolveSymlinks(settingsFile.FullName)
.Normalize(NormalizationForm.FormC)
.ToUpperInvariant();
var lockFileName = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(normalizedSettingsPath))).ToLowerInvariant();
return Path.Combine(executionContext.CacheDirectory.FullName, "workspace-config-locks", $"{lockFileName}.lock");
}
private string? GetNuGetPackagesCachePath()
{
var envPath = environment.GetEnvironmentVariable("NUGET_PACKAGES");
if (!string.IsNullOrEmpty(envPath))
{
return Path.GetFullPath(envPath);
}
var userProfile = executionContext.HomeDirectory.FullName;
if (!string.IsNullOrEmpty(userProfile))
{
return Path.GetFullPath(Path.Combine(userProfile, ".nuget", "packages"));
}
return null;
}
}
internal class ProjectLocatorException(string message, ProjectLocatorFailureReason failureReason) : System.Exception(message)
{
public ProjectLocatorFailureReason FailureReason { get; } = failureReason;
}
internal static class ProjectLocatorErrorHelper
{
public static (int ExitCode, string ErrorMessage) GetExitCodeAndMessage(ProjectLocatorException ex, bool projectOptionSpecifiedAsDirectory = false)
{
ArgumentNullException.ThrowIfNull(ex);
return ex.FailureReason switch
{
ProjectLocatorFailureReason.MultipleProjectFilesFound when projectOptionSpecifiedAsDirectory
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsMultipleAppHosts),
ProjectLocatorFailureReason.ProjectFileDoesntExist or ProjectLocatorFailureReason.NoProjectFileFound when projectOptionSpecifiedAsDirectory
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsNoAppHosts),
ProjectLocatorFailureReason.UnsupportedProjects
=> (CliExitCodes.SdkNotInstalled, InteractionServiceStrings.NoSupportedAppHostsFound),
ProjectLocatorFailureReason.ProjectFileNotAppHostProject
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.SpecifiedProjectFileNotAppHostProject),
ProjectLocatorFailureReason.ProjectFileDoesntExist
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionDoesntExist),
ProjectLocatorFailureReason.MultipleProjectFilesFound
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionNotSpecifiedMultipleAppHostsFound),
ProjectLocatorFailureReason.NoProjectFileFound
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.ProjectOptionNotSpecifiedNoAppHostsFound),
ProjectLocatorFailureReason.AppHostsMayNotBeBuildable
=> (CliExitCodes.FailedToFindProject, InteractionServiceStrings.UnbuildableAppHostsDetected),
_ => (CliExitCodes.FailedToFindProject, string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message))
};
}
}
internal enum ProjectLocatorFailureReason
{
ProjectFileDoesntExist,
ProjectFileNotAppHostProject,
MultipleProjectFilesFound,
NoProjectFileFound,
AppHostsMayNotBeBuildable,
UnsupportedProjects,
}
internal record AppHostProjectSearchResult(FileInfo? SelectedProjectFile, List<FileInfo> AllProjectFileCandidates)
{
internal bool WasExplicitDirectorySelectionPrompted { get; init; }
}
internal enum MultipleAppHostProjectsFoundBehavior
{
Prompt,
Throw,
None
}