// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Bundles;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Aspire.Shared.UserSecrets;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// Handler for .NET AppHost projects (.csproj and single-file .cs).
/// </summary>
internal sealed partial class DotNetAppHostProject : IAppHostProject
{
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly ICertificateService _certificateService;
private readonly AspireCliTelemetry _telemetry;
private readonly ProfilingTelemetry _profilingTelemetry;
private readonly IFeatures _features;
private readonly ILogger<DotNetAppHostProject> _logger;
private readonly TimeProvider _timeProvider;
private readonly IProjectUpdater _projectUpdater;
private readonly IDotNetSdkInstaller _sdkInstaller;
private readonly IBundleService _bundleService;
private readonly RunningInstanceManager _runningInstanceManager;
private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider;
private readonly Program.CliLoggingOptions _loggingOptions;
private readonly IAppHostInfoResolver _appHostInfoResolver;
private readonly IConfigurationService _configurationService;
private readonly IGracefulShutdownWindow _shutdownService;
private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler;
private readonly CliExecutionContext _executionContext;
private readonly IEnvironment _environment;
private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"];
private const string DirectLaunchDisabledConfigKey = "dotnetAppHostDirectLaunchDisabled";
private const string AspireAppHostSdkName = "Aspire.AppHost.Sdk";
private const string IsAspireHostProperty = "IsAspireHost";
private const string ProjectAppHostSourceFileName = "AppHost.cs";
private const string DirectoryBuildPropsName = "Directory.Build.props";
private const string DirectoryBuildTargetsName = "Directory.Build.targets";
internal static IReadOnlyCollection<string> ProjectExtensions { get; } =
Array.AsReadOnly([".csproj", ".fsproj", ".vbproj"]);
/// <summary>
/// Test seam: overrides <see cref="TryGetRepoLocalManagedPath"/>. When set, the override
/// is invoked instead of probing the real Aspire repo checkout. Tests use this so the
/// in-repo build artifact doesn't shadow the fake bundle layout they set up.
/// </summary>
internal static Func<string?>? RepoLocalManagedPathProviderOverride { get; set; }
public DotNetAppHostProject(
IDotNetCliRunner runner,
IInteractionService interactionService,
ICertificateService certificateService,
AspireCliTelemetry telemetry,
ProfilingTelemetry profilingTelemetry,
IFeatures features,
IProjectUpdater projectUpdater,
IDotNetSdkInstaller sdkInstaller,
IBundleService bundleService,
IEnvironment environment,
ILogger<DotNetAppHostProject> logger,
Diagnostics.FileLoggerProvider fileLoggerProvider,
Program.CliLoggingOptions loggingOptions,
IAppHostInfoResolver appHostInfoResolver,
IConfigurationService configurationService,
IGracefulShutdownWindow shutdownService,
IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler,
CliExecutionContext executionContext,
TimeProvider timeProvider)
{
_runner = runner;
_interactionService = interactionService;
_certificateService = certificateService;
_telemetry = telemetry;
_profilingTelemetry = profilingTelemetry;
_features = features;
_projectUpdater = projectUpdater;
_sdkInstaller = sdkInstaller;
_bundleService = bundleService;
_environment = environment;
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_loggingOptions = loggingOptions;
_appHostInfoResolver = appHostInfoResolver;
_configurationService = configurationService;
_shutdownService = shutdownService;
_gracefulShutdownSignaler = gracefulShutdownSignaler;
_executionContext = executionContext;
_timeProvider = timeProvider;
_runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider, _profilingTelemetry);
}
// ═══════════════════════════════════════════════════════════════
// IDENTITY
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public bool IsUnsupported { get; set; }
/// <inheritdoc />
public string LanguageId => KnownLanguageId.CSharp;
/// <inheritdoc />
public string DisplayName => "C# (.NET)";
/// <inheritdoc />
public bool SupportsLaunchProfiles => true;
// ═══════════════════════════════════════════════════════════════
// DETECTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public Task<string[]> GetDetectionPatternsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(s_detectionPatterns);
/// <inheritdoc />
public bool CanHandle(FileInfo appHostFile)
{
var extension = appHostFile.Extension.ToLowerInvariant();
// Handle project files (.csproj, .fsproj, .vbproj)
if (ProjectExtensions.Contains(extension))
{
// We can handle any project file - ValidateAsync will do deeper validation
return true;
}
// Handle single-file apphosts (apphost.cs)
if (extension == ".cs" && appHostFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase))
{
// Check for #:sdk Aspire.AppHost.Sdk directive
return IsValidSingleFileAppHost(appHostFile);
}
return false;
}
private static bool IsValidSingleFileAppHost(FileInfo candidateFile)
{
// Check no sibling .csproj files exist
var siblingCsprojFiles = candidateFile.Directory!.EnumerateFiles("*.csproj", SearchOption.TopDirectoryOnly);
if (siblingCsprojFiles.Any())
{
return false;
}
// Check for #:sdk Aspire.AppHost.Sdk directive
try
{
using var reader = candidateFile.OpenText();
string? line;
while ((line = reader.ReadLine()) is not null)
{
var trimmedLine = line.TrimStart();
if (trimmedLine.StartsWith("#:sdk Aspire.AppHost.Sdk", StringComparison.Ordinal))
{
return true;
}
}
}
catch
{
return false;
}
return false;
}
internal static bool IsLikelyAppHost(FileInfo projectFile)
{
if (!TryLoadProjectRoot(projectFile.FullName, out var root) || root is null)
{
// The file is missing, unreadable, or not well-formed XML. Before falling back to the
// name heuristic, still consult ancestor Directory.Build.* markers — those can promote
// an ordinary-named broken project to a real AppHost candidate, which should flow to
// MSBuild as "possibly unbuildable" rather than be silently rejected here.
if (AncestorDirectoryContainsAppHostMarker(projectFile.Directory))
{
return true;
}
return MatchesAppHostNameHeuristics(projectFile);
}
// 1) An Aspire AppHost marker declared inline in the project file itself.
if (ContainsAppHostMarker(root))
{
return true;
}
// 1b) The project file itself can also pull in a marker via a dynamic walk-up Import — for
// example
// <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props', ...))" />
// where the resolved file sets <IsAspireHost>true</IsAspireHost> or imports
// Aspire.AppHost.Sdk. The same fragility that prevents us from following these statically
// in ancestor Directory.Build.* files applies here, so apply the same narrow fallback:
// dynamic walk-up imports → candidate, ordinary static or unrelated-SDK imports → still
// filtered out by the cheap pre-check. Skipping this check leaves a regression hole where
// a normal-named project gets silently rejected before MSBuild evaluation runs.
if (ContainsDynamicWalkUpImport(root))
{
return true;
}
// 2) A co-located Directory.Build.props/.targets can promote an otherwise ordinary-looking project to
// an Aspire AppHost during MSBuild evaluation (for example by setting
// <IsAspireHost>true</IsAspireHost> or importing the Aspire.AppHost.Sdk). Tests in this repo do
// exactly this. Those files are parsed as XML and matched on element names, so a real *setter*
// element is detected while a mere *consumer* of the property
// (Condition="'$(IsAspireHost)' == 'true'") is ignored. A loose substring match would instead
// over-promote every sibling that only reads the property.
//
// MSBuild walks up the directory tree to import Directory.Build.props/.targets from the nearest
// ancestor that has one (see
// https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory#search-scope), and that
// ancestor commonly chains to further parents via $(DirectoryBuildPropsPath)-style imports. Walk
// *all* ancestors here rather than stopping at the project's own directory: missing an ancestor
// marker would falsely reject a legal AppHost before MSBuild ever runs, which is the exact failure
// mode that broke `aspire run` against explicit/settings AppHost paths.
if (AncestorDirectoryContainsAppHostMarker(projectFile.Directory))
{
return true;
}
// No inline or co-located Aspire marker. Fall back to the name heuristic.
return MatchesAppHostNameHeuristics(projectFile);
}
private static bool TryLoadProjectRoot(string path, out XElement? root)
{
try
{
root = XDocument.Load(path).Root;
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or XmlException)
{
root = null;
return false;
}
}
private static bool AncestorDirectoryContainsAppHostMarker(DirectoryInfo? directory)
{
// MSBuild imports only the NEAREST Directory.Build.props and the NEAREST Directory.Build.targets,
// discovered by walking parent directories up to the filesystem root. That discovery has NO .git
// boundary (see https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory), so a
// valid AppHost inside a nested repo, submodule, or worktree can still inherit a marker from above
// its inner .git. Crucially, MSBuild does NOT import every ancestor: an outer file is invisible to
// the project unless the nearest file explicitly chains to its parent. Verified against real
// MSBuild:
// outer/Directory.Build.props <IsAspireHost>true</IsAspireHost>
// outer/repo/Directory.Build.props (no marker, does NOT import its parent)
// outer/repo/proj/proj.csproj => dotnet msbuild -getProperty:IsAspireHost prints EMPTY
// Adding <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', ...))" /> to the
// nested file makes the same command print `true`. Walking every ancestor and accepting any marker
// would report a false positive for the first layout and force MSBuild evaluation for every ordinary
// project below a shadowed marker — the exact "evaluation storm" this prefilter exists to prevent.
//
// props and targets are searched independently because MSBuild resolves them independently: a marker
// in the nearest Directory.Build.targets counts even when the nearest Directory.Build.props does not
// chain, and vice versa.
return NearestDirectoryBuildFileChainContainsMarker(directory, DirectoryBuildPropsName)
|| NearestDirectoryBuildFileChainContainsMarker(directory, DirectoryBuildTargetsName);
}
private static bool NearestDirectoryBuildFileChainContainsMarker(DirectoryInfo? directory, string fileName)
{
// Walk up from the project directory following MSBuild's "nearest file, then explicit chain" rule
// for a single Directory.Build.* file name. At the first level that actually has the file we stop —
// unless that file has no marker but chains to its parent (continue up), or pulls in content we
// cannot resolve statically (conservatively treat the project as a candidate).
for (var current = directory; current is not null; current = current.Parent)
{
var filePath = Path.Combine(current.FullName, fileName);
if (!File.Exists(filePath))
{
continue;
}
if (!TryLoadProjectRoot(filePath, out var root) || root is null)
{
// The nearest file exists but can't be read/parsed. MSBuild would still evaluate it and it
// could set <IsAspireHost>true</IsAspireHost> or import Aspire.AppHost.Sdk, so keep the
// project as a candidate rather than silently rejecting it.
return true;
}
if (ContainsAppHostMarker(root))
{
return true;
}
// No marker in the nearest file. Whether MSBuild ever imports an OUTER file of the same name
// depends entirely on whether this file chains to its parent.
switch (ClassifyDirectoryBuildChaining(root, fileName))
{
case DirectoryBuildChaining.Uncertain:
// The file pulls in content we cannot resolve statically (a walk-up import to a
// non-conventional or out-of-tree target, or one we cannot parse). A marker could live
// behind it, so let the authoritative MSBuild evaluation decide.
return true;
case DirectoryBuildChaining.ChainsToParent:
// This file imports its parent of the same name, so MSBuild keeps reading upward.
// Continue the walk to the next nearest file to look for the marker there.
continue;
case DirectoryBuildChaining.StopsHere:
default:
// MSBuild imports only this nearest file for this name and it declares no marker; any
// outer file is shadowed and never evaluated. Promoting the project here would be a
// false positive, so stop.
return false;
}
}
return false;
}
private enum DirectoryBuildChaining
{
// The nearest marker-less file terminates the import chain for this name — MSBuild imports nothing
// further up, so any outer marker is shadowed.
StopsHere,
// The file imports its parent of the same name, so the walk should continue to the next file up.
ChainsToParent,
// The file pulls in content we cannot resolve statically; a marker could hide behind it.
Uncertain,
}
private static IEnumerable<string> SplitImportProjectEntries(string projectAttributeValue)
{
// MSBuild accepts import lists such as:
// <Import Project="../Directory.Build.props;Shared.props" />
// Each entry resolves independently, so classifying the unsplit value as one path can hide a
// conventional parent-chain import behind an unrelated entry.
return projectAttributeValue.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static DirectoryBuildChaining ClassifyDirectoryBuildChaining(XElement root, string chainFileName)
{
// Decide, for a marker-less Directory.Build.<props|targets> file, whether MSBuild would keep
// importing an OUTER file of the same name (ChainsToParent), whether it pulls in unresolvable
// content that could hide a marker (Uncertain), or whether it terminates the chain for this name
// (StopsHere). Only imports that provably reach the parent-of-same-name advance the chain; ordinary
// shared infrastructure imports (Arcade, analyzer polyfills, Directory.Packages.props, ...) are
// inert here — otherwise essentially every project in a .NET repo would be promoted.
var chains = false;
foreach (var import in root.Descendants().Where(e => e.Name.LocalName.Equals("Import", StringComparison.Ordinal)))
{
var projectAttributeValue = import.Attribute("Project")?.Value;
if (projectAttributeValue is null)
{
continue;
}
foreach (var project in SplitImportProjectEntries(projectAttributeValue))
{
if (WalkUpFunctionCallStartRegex().IsMatch(project))
{
// A tree-walking import: $([MSBuild]::GetPathOfFileAbove/GetDirectoryNameOfFileAbove(...)).
if (TryGetWalkUpImport(project, out var walkUpImport) && IsAlreadyEnumeratedDirectoryBuildChain(walkUpImport))
{
// Immediate-parent chaining import of a conventional Directory.Build.* file: its target
// is the nearest file at or above this file's parent — the exact next level this walk
// visits by name. This shortcut is only valid for the same-name chain. A props file can
// explicitly import a parent targets file while a nearer targets file shadows the
// normal targets pass, so cross-name imports must remain uncertain.
if (walkUpImport.AnchorFileName.Equals(chainFileName, StringComparison.Ordinal))
{
chains = true;
continue;
}
return DirectoryBuildChaining.Uncertain;
}
// Walk-up import to a non-conventional target, a level-skipping or out-of-tree starting
// directory, or one we cannot parse — MSBuild may import a file this loop would never reach,
// so the whole file is uncertain and the project stays a candidate.
return DirectoryBuildChaining.Uncertain;
}
switch (ClassifyStaticImportChaining(project, chainFileName).Chaining)
{
case DirectoryBuildChaining.Uncertain:
return DirectoryBuildChaining.Uncertain;
case DirectoryBuildChaining.ChainsToParent:
chains = true;
break;
case DirectoryBuildChaining.StopsHere:
default:
break;
}
}
}
return chains ? DirectoryBuildChaining.ChainsToParent : DirectoryBuildChaining.StopsHere;
}
/// <summary>
/// Result of classifying one ordinary (non-walk-up) <c><Import Project="..."></c>.
/// </summary>
/// <param name="Chaining">How the import affects the Directory.Build.* chain walk.</param>
/// <param name="UncertainTargetPath">
/// When <paramref name="Chaining"/> is <see cref="DirectoryBuildChaining.Uncertain"/>, the imported path
/// as written after expanding <c>$(MSBuildThisFileDirectory)</c> — relative to the importing file's
/// directory, or rooted. <see langword="null"/> when the target cannot be resolved without MSBuild
/// (an unexpandable property/item reference, or a wildcard). Callers that need to fingerprint the
/// admitted dependency use this to tell "exact file" apart from "unknowable file".
/// </param>
private readonly record struct StaticImportClassification(
DirectoryBuildChaining Chaining,
string? UncertainTargetPath);
private static StaticImportClassification ClassifyStaticImportChaining(string projectAttributeValue, string chainFileName)
{
// Classify one ordinary (non-walk-up) <Import Project="..."> inside a marker-less
// Directory.Build.<props|targets>. Raw values seen in the wild, and how each is treated:
//
// "../Directory.Build.props" -> ChainsToParent (verified under real MSBuild)
// "$(MSBuildThisFileDirectory)..\Directory.Build.props" -> ChainsToParent; $(MSBuildThisFileDirectory)
// is a reserved property that always expands
// to THIS file's directory plus a trailing
// slash, so the target is statically knowable
// "$(MSBuildThisFileDirectory)../../Directory.Build.props"-> Uncertain (skips the level this walk
// visits next, so an outer marker could be
// imported that the walk never sees)
// "$(RepoRoot)Directory.Build.props" -> Uncertain (conventional name, but at a
// directory only MSBuild can compute)
// "$(RepositoryEngineeringDir)NullablePolyfill.targets" -> StopsHere (ordinary shared infrastructure)
// "Sdk.props" / "../Versions.props" -> StopsHere (ordinary static import)
//
// Only imports that provably reach the parent-of-same-name advance the chain. Ordinary shared
// infrastructure imports stay inert — following those would over-promote essentially every project in
// a .NET repo (this repo's own root Directory.Build.targets imports
// $(RepositoryEngineeringDir)/NullablePolyfill.targets). But an import that lands on a *conventional*
// Directory.Build.* file at a location we cannot pin down must NOT be treated as inert: MSBuild
// resolves it and can see an outer marker there, while reporting StopsHere would reject the project.
var trimmed = projectAttributeValue.Trim();
if (trimmed.Length == 0)
{
return new StaticImportClassification(DirectoryBuildChaining.StopsHere, null);
}
// $(MSBuildThisFileDirectory) is the one property we can expand ourselves: MSBuild defines it as the
// directory of the file containing the import, with a trailing slash.
// https://learn.microsoft.com/visualstudio/msbuild/msbuild-reserved-and-well-known-properties
const string thisFileDirectory = "$(MSBuildThisFileDirectory)";
var startsWithThisFileDirectory = trimmed.StartsWith(thisFileDirectory, StringComparison.OrdinalIgnoreCase);
var relativePath = startsWithThisFileDirectory
? trimmed[thisFileDirectory.Length..].TrimStart('/', '\\')
: trimmed;
if (relativePath.Contains('$') || relativePath.Contains('@'))
{
// An unexpandable property/item reference remains. The literal tail of the final path segment is
// often still readable (e.g. "$(RepoRoot)Directory.Build.props"), and that is enough to tell an
// inert infrastructure import apart from one that could pull in an outer Directory.Build.* marker.
// The directory it lands in is still unknown, so there is no path to fingerprint.
var expressionFileName = GetLiteralFileNameSuffix(GetFinalPathSegment(relativePath));
return CanMatchConventionalDirectoryBuildFileName(expressionFileName)
? new StaticImportClassification(DirectoryBuildChaining.Uncertain, null)
: new StaticImportClassification(DirectoryBuildChaining.StopsHere, null);
}
var fileName = GetFinalPathSegment(relativePath);
var uncertainTargetPath = ContainsWildcard(relativePath) ? null : relativePath;
if (!IsConventionalDirectoryBuildFileName(fileName))
{
// A differently-cased conventional name can resolve to the same file on Windows, or to a
// distinct explicitly imported file on a case-sensitive filesystem. Wildcards can also match
// one of those files. Any such target can carry the marker, so it is not safe to classify the
// import as unrelated.
return CanMatchConventionalDirectoryBuildFileName(fileName)
? new StaticImportClassification(DirectoryBuildChaining.Uncertain, uncertainTargetPath)
: new StaticImportClassification(DirectoryBuildChaining.StopsHere, null);
}
// A wildcard import expands to every match at evaluation time, so the imported set is not a single
// knowable path. IsConventionalDirectoryBuildFileName above already rejects a wildcard in the file
// name itself, so only the directory portion can still hold one (e.g. "../*/Directory.Build.props").
if (Path.IsPathRooted(relativePath))
{
// An absolute path can point at a Directory.Build.* anywhere on disk, including outside this
// project's ancestor chain, so the walk cannot prove the outer file is shadowed.
return new StaticImportClassification(
DirectoryBuildChaining.Uncertain,
startsWithThisFileDirectory ? null : uncertainTargetPath);
}
// Count how many levels up the directory portion travels. Anything other than "exactly one level up"
// either cannot advance this walk correctly or leaves the enumerated chain entirely.
var upLevels = 0;
var directorySegments = relativePath.Split('/', '\\');
for (var i = 0; i < directorySegments.Length - 1; i++)
{
switch (directorySegments[i].Trim())
{
case "" or ".":
continue;
case "..":
upLevels++;
continue;
default:
// A named segment descends into a directory this walk never enumerates.
return new StaticImportClassification(DirectoryBuildChaining.Uncertain, uncertainTargetPath);
}
}
return upLevels switch
{
// Same directory: a same-name import is a self-import, which MSBuild ignores. A cross-name
// sibling import is not necessarily covered by the other pass because that pass can stop at a
// file nearer to the project before reaching this directory.
0 => fileName.Equals(chainFileName, StringComparison.Ordinal)
? new StaticImportClassification(DirectoryBuildChaining.StopsHere, null)
: new StaticImportClassification(DirectoryBuildChaining.Uncertain, uncertainTargetPath),
// Immediate parent: advances the chain when it targets THIS name. A cross-name import is not
// covered by the other pass because that pass starts at the project directory and can stop at a
// nearer marker-less file before reaching the explicitly imported parent file.
1 => fileName.Equals(chainFileName, StringComparison.Ordinal)
? new StaticImportClassification(DirectoryBuildChaining.ChainsToParent, null)
: new StaticImportClassification(DirectoryBuildChaining.Uncertain, uncertainTargetPath),
// Two or more levels up jumps over the file this walk visits next, exactly like a level-skipping
// GetPathOfFileAbove start, so an outer marker could be imported that this walk never reaches.
_ => new StaticImportClassification(DirectoryBuildChaining.Uncertain, uncertainTargetPath),
};
}
private static bool ContainsWildcard(string path) => path.Contains('*') || path.Contains('?');
private static string GetFinalPathSegment(string path)
{
var trimmed = path.Trim();
var lastSeparator = Math.Max(trimmed.LastIndexOf('/'), trimmed.LastIndexOf('\\'));
return lastSeparator >= 0 ? trimmed[(lastSeparator + 1)..].Trim() : trimmed;
}
private static string GetLiteralFileNameSuffix(string segment)
{
// Recover the literal file name from a path segment that may begin with an MSBuild expression. The
// expression itself expands to a directory prefix, so only the text after its closing parenthesis
// names the file:
// "$(RepoRoot)Directory.Build.props" -> "Directory.Build.props"
// "Directory.Build.props" -> "Directory.Build.props"
// "$(Flavor).props" -> ".props"
// "$(SharedPropsFileName)" -> "" (nothing literal left to identify)
var lastExpressionEnd = segment.LastIndexOf(')');
var tail = (lastExpressionEnd >= 0 ? segment[(lastExpressionEnd + 1)..] : segment).Trim();
return tail.Contains('$') || tail.Contains('@') ? string.Empty : tail;
}
private static bool IsImmediateParentChainStart(string? startDirectoryArg)
{
// A conventional Directory.Build.* walk-up import only lets this ancestor walk keep going upward when
// its search begins at the importing file's IMMEDIATE parent directory — the exact
// '$(MSBuildThisFileDirectory)../' shape MSBuild's own Directory.Build.* chaining uses (verified: it
// is the shape in this repo's src/ and tests/ Directory.Build.* files). GetPathOfFileAbove searches
// the starting directory AND every directory above it, so starting at the immediate parent means
// "nearest Directory.Build.* at or above the parent" — exactly the next file this loop visits by name.
//
// Two other shapes must NOT advance the loop:
// * A level-skipping start ('$(MSBuildThisFileDirectory)../../', etc.) begins the search ABOVE the
// immediate parent, so MSBuild can jump over an intervening marker-less Directory.Build.props and
// import a marker farther up. This loop would instead visit that skipped intermediate next and
// stop there, producing a false negative. Verified against real MSBuild: with a '../../' start and
// a marker two levels up, `dotnet msbuild -getProperty:IsAspireHost` prints `true`, while a
// nearest-file walk that stops at the intermediate would say false.
// * An omitted or same-directory start ('$(MSBuildThisFileDirectory)') resolves to the importing
// file's own directory. GetPathOfFileAbove includes the starting directory, so it finds THIS file
// (a self-import) and never reaches the parent — it does not chain upward at all.
// GetPathOfFileAbove / GetDirectoryNameOfFileAbove also accept an ARBITRARY starting directory, e.g.
// $([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(ExternalBuildRoot)'))
// which resolves a Directory.Build.props this walk never inspects (verified to pull in an external
// IsAspireHost=true under real MSBuild). Any of these non-immediate-parent shapes is left to the
// caller as "uncertain", keeping the project a candidate rather than risking a false negative.
if (string.IsNullOrWhiteSpace(startDirectoryArg))
{
return false;
}
var start = StripQuotes(startDirectoryArg).Trim();
const string thisFileDirectory = "$(MSBuildThisFileDirectory)";
if (!start.StartsWith(thisFileDirectory, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// The remainder must resolve to exactly one directory level up: a single '..' segment, plus any
// number of '.'/empty no-op segments. A second '..' skips past the immediate parent, and any named
// segment could descend or jump out of the enumerated chain — both disqualify the shortcut.
var upLevels = 0;
foreach (var rawSegment in start[thisFileDirectory.Length..].Split('/', '\\'))
{
switch (rawSegment.Trim())
{
case "" or ".":
continue;
case "..":
if (++upLevels > 1)
{
return false;
}
continue;
default:
return false;
}
}
return upLevels == 1;
}
private static bool IsAlreadyEnumeratedDirectoryBuildChain(in WalkUpImport import)
{
// Decide whether a walk-up import provably resolves to a file the ancestor walk already enumerates
// by name, so skipping it cannot hide a marker. That is true for exactly one shape:
//
// <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
//
// GetPathOfFileAbove(file, start) returns the nearest <file> at or above <start>, so with an
// immediate-parent start and a conventional name the target is literally "the next Directory.Build.*
// of this name the walk visits". Three restrictions keep the shortcut sound:
//
// * Only GetPathOfFileAbove. GetDirectoryNameOfFileAbove(start, anchor) returns the DIRECTORY that
// holds <anchor>, and the imported file is whatever is appended after the call. The anchor — not
// the appended name — selects the directory, so
// $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/Directory.Build.props
// imports Directory.Build.props from whichever ancestor carries Repo.marker. That can be far above
// a nearer marker-less Directory.Build.props which shadows the ordinary chain, so the walk would
// stop early and reject a project MSBuild promotes.
// * No appended path. Once anything is concatenated onto the call, the anchor argument stops being
// the imported file for GetPathOfFileAbove too (e.g. `))/../Directory.Build.props`), which brings
// back the same anchor/target confusion.
// * A conventional target name and an immediate-parent start (see IsImmediateParentChainStart).
return import.IsGetPathOfFileAbove
&& !import.HasAppendedPath
&& IsConventionalDirectoryBuildFileName(import.AnchorFileName)
&& IsImmediateParentChainStart(import.StartDirectoryArg);
}
/// <summary>
/// Files outside the conventional <c>Directory.Build.*</c> / <c>Directory.Packages.*</c> set that the
/// prefilter allowed to decide whether a project is an AppHost candidate.
/// </summary>
/// <param name="AncestorSearchFileNames">
/// File names reached by accepted walk-up imports, ordinal-sorted and de-duplicated. A walk-up import
/// binds to the nearest file of that name at or above the importing file, so statting each name at every
/// ancestor level covers every file MSBuild could bind it to.
/// </param>
/// <param name="ExactFilePaths">
/// Absolute paths of accepted static imports, ordinal-sorted and de-duplicated. Unlike walk-up imports
/// these name exactly one file, so they are statted directly — including targets outside the project's
/// ancestor chain, such as <c>../shared/Directory.Build.props</c>.
/// </param>
/// <param name="HasUnfingerprintableImport">
/// <see langword="true"/> when at least one accepted import resolves somewhere no filesystem fingerprint
/// can cover: an MSBuild expression where the file name belongs, a walk-up rooted outside the ancestor
/// chain, an appended path that does not name a single file in the resolved directory, or a wildcard.
/// </param>
internal readonly record struct AppHostImportDependencies(
IReadOnlyCollection<string> AncestorSearchFileNames,
IReadOnlyCollection<string> ExactFilePaths,
bool HasUnfingerprintableImport);
/// <summary>
/// Reports the custom imports that <see cref="IsLikelyAppHost"/> honors for <paramref name="projectFile"/>,
/// so <c>AppHostInfoDiskCache</c> can either fingerprint them or refuse to cache. Those imports are the
/// paths by which a marker reaches MSBuild without touching any file the cache's conventional walk
/// already stats — for example <c>$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props'))</c> or
/// <c><Import Project="$(MSBuildThisFileDirectory)../shared/Directory.Build.props" /></c>, where
/// flipping <c>IsAspireHost</c> inside the imported file changes the answer while leaving every tracked
/// mtime untouched.
/// </summary>
internal static AppHostImportDependencies CollectImportDependencies(FileInfo projectFile)
{
var builder = new ImportDependencyBuilder();
// Mirror IsLikelyAppHost exactly: it consults the project file's own walk-up imports (step 1b) and
// then the NEAREST Directory.Build.props / Directory.Build.targets chain (step 2). Files outside
// those reachable chains are shadowed — MSBuild never evaluates them — so an unresolvable import
// sitting in one must not disable caching for this project.
if (TryLoadProjectRoot(projectFile.FullName, out var projectRoot) && projectRoot is not null)
{
CollectFileImportDependencies(projectRoot, projectFile.Directory, DirectoryBuildPropsName, builder, staticImportsAdmitProject: false);
}
CollectDirectoryBuildChainDependencies(projectFile.Directory, DirectoryBuildPropsName, builder);
CollectDirectoryBuildChainDependencies(projectFile.Directory, DirectoryBuildTargetsName, builder);
return builder.Build();
}
private static void CollectDirectoryBuildChainDependencies(DirectoryInfo? directory, string fileName, ImportDependencyBuilder builder)
{
// Replay NearestDirectoryBuildFileChainContainsMarker's traversal for one conventional file name and
// record what each visited file lets the classifier accept. Anything the walk does not visit cannot
// have contributed to the verdict, so it contributes no cache dependency either.
for (var current = directory; current is not null; current = current.Parent)
{
var filePath = Path.Combine(current.FullName, fileName);
if (!File.Exists(filePath))
{
continue;
}
if (!TryLoadProjectRoot(filePath, out var root) || root is null || ContainsAppHostMarker(root))
{
// The verdict was decided by this file, whose own mtime the conventional walk already
// fingerprints. Nothing further up is imported for this name.
return;
}
CollectFileImportDependencies(root, current, fileName, builder, staticImportsAdmitProject: true);
switch (ClassifyDirectoryBuildChaining(root, fileName))
{
case DirectoryBuildChaining.ChainsToParent:
continue;
case DirectoryBuildChaining.Uncertain:
case DirectoryBuildChaining.StopsHere:
default:
return;
}
}
}
private static void CollectFileImportDependencies(
XElement root,
DirectoryInfo? containingDirectory,
string chainFileName,
ImportDependencyBuilder builder,
bool staticImportsAdmitProject)
{
foreach (var import in root.Descendants().Where(e => e.Name.LocalName.Equals("Import", StringComparison.Ordinal)))
{
var projectAttributeValue = import.Attribute("Project")?.Value;
if (projectAttributeValue is null)
{
continue;
}
foreach (var project in SplitImportProjectEntries(projectAttributeValue))
{
if (WalkUpFunctionCallStartRegex().IsMatch(project))
{
RecordWalkUpImportDependency(project, builder);
continue;
}
if (!staticImportsAdmitProject)
{
// Only ContainsDynamicWalkUpImport runs over the .csproj itself, so a static import there
// never admits the project and cannot make the prefilter's answer depend on that file.
continue;
}
var classification = ClassifyStaticImportChaining(project, chainFileName);
if (classification.Chaining is not DirectoryBuildChaining.Uncertain)
{
// ChainsToParent lands on the next file this walk visits by name, which the conventional
// fingerprint already stats; StopsHere means the import never contributed to the verdict.
continue;
}
if (classification.UncertainTargetPath is not { } targetPath
|| containingDirectory is null
|| !TryResolveImportPath(containingDirectory.FullName, targetPath, out var resolvedPath))
{
builder.MarkUnfingerprintable();
continue;
}
builder.AddExactPath(resolvedPath);
}
}
}
private static void RecordWalkUpImportDependency(string projectAttributeValue, ImportDependencyBuilder builder)
{
if (!TryGetWalkUpImport(projectAttributeValue, out var walkUpImport))
{
builder.MarkUnfingerprintable();
return;
}
if (IsAlreadyEnumeratedDirectoryBuildChain(walkUpImport))
{
// Conventional Directory.Build.* chaining resolves to files the cache already stats at every
// ancestor level.
return;
}
if (walkUpImport.AnchorFileName.Length == 0
|| ContainsWildcard(walkUpImport.AnchorFileName)
|| !IsAncestorChainSearchStart(walkUpImport.StartDirectoryArg))
{
// The searched file name is an MSBuild expression or a glob, or the search starts outside this
// project's ancestor chain (e.g. '$(ExternalBuildRoot)'). Either way the resolved file is not
// reachable by the ancestor walk that produces the fingerprint.
builder.MarkUnfingerprintable();
return;
}
// The anchor is tracked even when it is not the imported file: it is what selects WHICH ancestor
// directory the import resolves in, so creating or deleting an anchor changes the resolved import.
builder.AddAncestorSearchName(walkUpImport.AnchorFileName);
if (!walkUpImport.HasAppendedPath)
{
return;
}
if (!TryGetAppendedImportFileName(walkUpImport, out var appendedFileName))
{
builder.MarkUnfingerprintable();
return;
}
builder.AddAncestorSearchName(appendedFileName);
}
private static bool TryGetAppendedImportFileName(in WalkUpImport import, out string fileName)
{
// Work out the single file name an appended suffix produces, which differs per helper because the
// two return different things. Raw values and the file MSBuild ends up importing:
//
// $([MSBuild]::GetPathOfFileAbove('Aspire.Common', '$(MSBuildThisFileDirectory)../')).props
// -> GetPathOfFileAbove returns "<dir>/Aspire.Common", so the text concatenates onto the FILE
// NAME and the import is "<dir>/Aspire.Common.props".
// $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/Custom.props
// -> GetDirectoryNameOfFileAbove returns "<dir>" with no trailing separator, so the suffix must
// start with one and the import is "<dir>/Custom.props".
//
// Anything else — an expression, a glob, a nested sub-directory, or a suffix whose separator usage
// does not match the helper (which would treat a file as a directory, or splice a directory name and
// a file name together) — cannot be reduced to one name that statting ancestor directories covers.
// Docs: https://learn.microsoft.com/visualstudio/msbuild/property-functions#msbuild-property-functions
fileName = string.Empty;
var appended = import.AppendedPath;
if (appended.Length == 0 || appended.Contains('$') || appended.Contains('@') || ContainsWildcard(appended))
{
return false;
}
if (import.IsGetPathOfFileAbove)
{
if (appended.AsSpan().IndexOfAny('/', '\\') >= 0)
{
return false;
}
fileName = import.AnchorFileName + appended;
return true;
}
if (appended[0] is not ('/' or '\\'))
{
return false;
}
var remainder = appended[1..];
if (remainder.Length == 0 || remainder.AsSpan().IndexOfAny('/', '\\') >= 0)
{
return false;
}
fileName = remainder;
return true;
}
private static bool TryResolveImportPath(string containingDirectory, string importPath, out string fullPath)
{
try
{
// MSBuild accepts '\' as a separator on every platform and normalizes it, but Path.GetFullPath
// only does so on Windows — so "..\shared\Directory.Build.props" would collapse into a single
// bogus segment on macOS/Linux. Normalize first so both spellings resolve identically.
var normalized = importPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
fullPath = Path.GetFullPath(Path.Combine(containingDirectory, normalized));
return true;
}
catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException or IOException)
{
fullPath = string.Empty;
return false;
}
}
private sealed class ImportDependencyBuilder
{
private readonly SortedSet<string> _ancestorSearchFileNames = new(StringComparer.Ordinal);
private readonly SortedSet<string> _exactFilePaths = new(StringComparer.Ordinal);
private bool _hasUnfingerprintableImport;
public void AddAncestorSearchName(string fileName) => _ancestorSearchFileNames.Add(fileName);
public void AddExactPath(string fullPath) => _exactFilePaths.Add(fullPath);
public void MarkUnfingerprintable() => _hasUnfingerprintableImport = true;
public AppHostImportDependencies Build()
=> new(_ancestorSearchFileNames, _exactFilePaths, _hasUnfingerprintableImport);
}
private static bool IsAncestorChainSearchStart(string? startDirectoryArg)
{
// Whether a walk-up search is guaranteed to land on a directory the ancestor walk enumerates. Unlike
// IsImmediateParentChainStart this accepts ANY number of '..' levels, because every one of them is
// still an ancestor of the importing file (and therefore of the project). Raw forms:
// null / '' -> defaults to $(MSBuildThisFileDirectory): in chain
// '$(MSBuildThisFileDirectory)../' -> in chain
// '$(MSBuildThisFileDirectory)../../' -> in chain
// '$(MSBuildThisFileDirectory)../peer/' -> NOT in chain (a named segment leaves the ancestor path)
// '$(ExternalBuildRoot)' / '/abs/path' -> NOT in chain
if (startDirectoryArg is null)
{
return true;
}
var start = StripQuotes(startDirectoryArg).Trim();
if (start.Length == 0)
{
return true;
}
const string thisFileDirectory = "$(MSBuildThisFileDirectory)";
if (!start.StartsWith(thisFileDirectory, StringComparison.OrdinalIgnoreCase))
{
return false;
}
foreach (var rawSegment in start[thisFileDirectory.Length..].Split('/', '\\'))
{
switch (rawSegment.Trim())
{
case "" or "." or "..":
continue;
default:
return false;
}
}
return true;
}
private static bool ContainsDynamicWalkUpImport(XElement root)
{
// Match <Import Project="..."> values that use MSBuild's tree-walking path helpers:
// $([MSBuild]::GetPathOfFileAbove('<filename>', '<starting-dir>'))
// $([MSBuild]::GetDirectoryNameOfFileAbove('<starting-dir>', '<filename>'))
// These resolve at evaluation time by walking parent directories, and they routinely point at
// files (RepoTesting.props, custom shared targets) that we do not enumerate by name in the
// ancestor walk. A statically-named Import like <Import Project="NullablePolyfill.targets" />
// does NOT match — it points at a fixed file the project author already named, and treating
// those as uncertain would over-promote every project in a repo whose root Directory.Build.*
// imports Arcade or common polyfills. The Sdk attribute is intentionally not consulted here:
// <Import Sdk="Aspire.AppHost.Sdk" .../> is already recognized as a positive marker by
// ContainsAppHostMarker, and any other <Import Sdk="..."> brings in an unrelated SDK whose
// contents will not declare Aspire markers.
//
// Match case-insensitively because MSBuild property function names are themselves
// case-insensitive — `$([MSBuild]::getpathoffileabove(...))` and
// `$([MSBuild]::GetPathOfFileAbove(...))` resolve to the same path at evaluation time, so a
// case-sensitive substring check here would silently filter out the lower/mixed-case variants
// and re-open the false-negative window this fallback is meant to close.
//
// Do not apply the conventional Directory.Build.* chaining shortcut here. That shortcut is sound
// only while following a Directory.Build.* file: a project-file import can start at its parent,
// bypass a nearer marker-less auto-import, and explicitly reach an outer marker-bearing file.
// Docs: https://learn.microsoft.com/visualstudio/msbuild/property-functions#msbuild-property-functions
foreach (var import in root.Descendants().Where(e => e.Name.LocalName.Equals("Import", StringComparison.Ordinal)))
{
var projectAttributeValue = import.Attribute("Project")?.Value;
if (projectAttributeValue is null)
{
continue;
}
foreach (var project in SplitImportProjectEntries(projectAttributeValue))
{
// Match the function-call *shape* (name followed by `(`) rather than a raw substring — a static
// import like <Import Project="build/GetPathOfFileAbove.props" /> contains the helper name as
// path text but is not a function call, and treating it as uncertain would over-promote ordinary
// projects.
if (!WalkUpFunctionCallStartRegex().IsMatch(project))
{
continue;
}
// Either a walk-up we cannot resolve statically (the arguments are themselves MSBuild
// expressions, the call is malformed) or one that lands somewhere the ancestor walk does not
// enumerate. Be conservative and treat the project as a candidate.
return true;
}
}
return false;
}
/// <summary>
/// One parsed <c>$([MSBuild]::GetPathOfFileAbove(...))</c> / <c>$([MSBuild]::GetDirectoryNameOfFileAbove(...))</c>
/// import, split into the pieces the classifier and the cache fingerprint need.
/// </summary>
/// <param name="IsGetPathOfFileAbove">
/// <see langword="true"/> for the file-returning helper, <see langword="false"/> for the
/// directory-returning one. The two differ in which argument is the anchor and in whether the call alone
/// identifies an imported file.
/// </param>
/// <param name="AnchorFileName">
/// The file name the walk-up searches for — argument 1 of <c>GetPathOfFileAbove(file, start)</c> and
/// argument 2 of <c>GetDirectoryNameOfFileAbove(start, file)</c>. Empty when it is not statically
/// determinable (for example <c>GetPathOfFileAbove($(SharedPropsName))</c>).
/// </param>
/// <param name="StartDirectoryArg">
/// The directory the search starts from, or <see langword="null"/> when omitted — in which case
/// <c>GetPathOfFileAbove</c> defaults to <c>$(MSBuildThisFileDirectory)</c>.
/// </param>
/// <param name="AppendedPath">
/// Raw path text concatenated after the call, or empty when there is none. It is kept verbatim rather
/// than reduced to a file name because how it combines with the call result depends on the helper — see
/// <see cref="TryGetAppendedImportFileName"/>.
/// </param>
private readonly record struct WalkUpImport(
bool IsGetPathOfFileAbove,
string AnchorFileName,
string? StartDirectoryArg,
string AppendedPath)
{
public bool HasAppendedPath => AppendedPath.Length > 0;
}
private static bool TryGetWalkUpImport(string projectAttributeValue, out WalkUpImport import)
{
// Parse one MSBuild property-function walk-up call out of the Import Project value. Raw forms:
// $([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))
// $([MSBuild]::GetPathOfFileAbove('Aspire.Common.props'))
// $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)..', 'Directory.Build.props'))/Custom.props
// $([MSBuild]::getpathoffileabove(Directory.Packages.props, $(MSBuildThisFileDirectory)..))
// Arguments may be single-quoted, double-quoted, or unquoted, and the helper names are matched
// case-insensitively because MSBuild property function names are case-insensitive.
//
// The imported file is NOT always the anchor argument: when path text follows the call, that
// appended text names the imported file and the anchor only selects the directory. Callers need both
// parts, so they are reported separately instead of being collapsed into a single "target".
//
// Returns false when no walk-up call is recognized or when the call is malformed (unbalanced
// parentheses); callers treat that as "uncertain".
import = default;
var callMatch = WalkUpFunctionCallStartRegex().Match(projectAttributeValue);
if (!callMatch.Success || projectAttributeValue[..callMatch.Index].Trim().Length > 0)
{
return false;
}
var isGetPathOfFileAbove = callMatch.Groups[1].Value.Equals("GetPathOfFileAbove", StringComparison.OrdinalIgnoreCase);
var argsStart = callMatch.Index + callMatch.Length;
if (!TryParseFunctionCallArgs(projectAttributeValue, argsStart, out var args, out var afterCloseParen))
{
return false;
}
// The anchor file name and the starting directory sit in opposite argument slots for the two helpers.
var anchorArg = isGetPathOfFileAbove
? (args.Count >= 1 ? args[0] : null)
: (args.Count >= 2 ? args[1] : null);
var startDirectoryArg = isGetPathOfFileAbove
? (args.Count >= 2 ? args[1] : null)
: (args.Count >= 1 ? args[0] : null);
var anchorFileName = anchorArg is null ? string.Empty : StripQuotes(anchorArg).Trim();
if (anchorFileName.Contains('$') || anchorFileName.Contains('@'))
{
// The anchor is itself an MSBuild expression; only MSBuild knows which file is searched for.
anchorFileName = string.Empty;
}
// The supported fingerprintable shape is a standalone $([MSBuild]::...) expression with an
// optional literal suffix. Prefixes or enclosing property functions can change the imported path
// in ways this parser cannot model, so callers must treat them as unfingerprintable.
var suffixStart = afterCloseParen;
while (suffixStart < projectAttributeValue.Length && char.IsWhiteSpace(projectAttributeValue[suffixStart]))
{
suffixStart++;
}
if (suffixStart >= projectAttributeValue.Length || projectAttributeValue[suffixStart] != ')')
{
return false;
}
suffixStart++;
var appendedPath = suffixStart < projectAttributeValue.Length
? projectAttributeValue[suffixStart..].Trim()
: string.Empty;
if (appendedPath.Contains('(') || appendedPath.Contains(')'))
{
return false;
}
import = new WalkUpImport(
isGetPathOfFileAbove,
anchorFileName,
startDirectoryArg,
appendedPath);
return true;
}
private static bool TryParseFunctionCallArgs(string text, int startIndex, out List<string> args, out int afterCloseParen)
{
// Hand-rolled comma-splitter with balanced-paren and quote awareness. MSBuild property-
// function arguments can be:
// * Single-quoted strings ('like this')
// * Double-quoted strings ("like this")
// * Unquoted scalars (Directory.Build.props)
// * MSBuild property references that contain parens, e.g. $(MSBuildThisFileDirectory)..
// * Other nested function calls
// We need to find the top-level commas separating arguments and the `)` that closes the
// call we started inside. Regex with `[^,)]` would prematurely terminate on the `)` inside
// a $(...) reference; a tiny state machine handles this correctly.
args = new List<string>();
var current = new StringBuilder();
var depth = 1;
var inSingleQuote = false;
var inDoubleQuote = false;
for (var i = startIndex; i < text.Length; i++)
{
var c = text[i];
if (inSingleQuote)
{
if (c == '\'')
{
inSingleQuote = false;
}
current.Append(c);
continue;
}
if (inDoubleQuote)
{
if (c == '"')
{
inDoubleQuote = false;
}
current.Append(c);
continue;
}
switch (c)
{
case '\'':
inSingleQuote = true;
current.Append(c);
break;
case '"':
inDoubleQuote = true;
current.Append(c);
break;
case '(':
depth++;
current.Append(c);
break;
case ')':
depth--;
if (depth == 0)
{
args.Add(current.ToString().Trim());
afterCloseParen = i + 1;
return true;
}
current.Append(c);
break;
case ',' when depth == 1:
args.Add(current.ToString().Trim());
current.Clear();
break;
default:
current.Append(c);
break;
}
}
afterCloseParen = text.Length;
return false;
}
private static string StripQuotes(string value)
{
var trimmed = value.Trim();
if (trimmed.Length >= 2
&& ((trimmed[0] == '\'' && trimmed[^1] == '\'')
|| (trimmed[0] == '"' && trimmed[^1] == '"')))
{
return trimmed[1..^1];
}
return trimmed;
}
private static bool IsConventionalDirectoryBuildFileName(string fileName)
{
// Only skip dynamic walk-up imports for the exact file names the ancestor walk probes.
// On case-sensitive filesystems, differently-cased names can resolve to different files;
// keep those candidates flowing to MSBuild instead of assuming we already inspected them.
return fileName.Equals(DirectoryBuildPropsName, StringComparison.Ordinal)
|| fileName.Equals(DirectoryBuildTargetsName, StringComparison.Ordinal);
}
private static bool CanMatchConventionalDirectoryBuildFileName(string fileName)
=> System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(fileName, DirectoryBuildPropsName, ignoreCase: true)
|| System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(fileName, DirectoryBuildTargetsName, ignoreCase: true);
// Strict MSBuild property-function call shape: the full $([MSBuild]::Function( prefix. Static
// import paths that merely contain the helper name as text (e.g.
// "build/GetPathOfFileAbove('Shared.props').props") do NOT match because they lack the
// $([MSBuild]::...) wrapper that MSBuild requires for property-function invocation.
// Docs: https://learn.microsoft.com/visualstudio/msbuild/property-functions#calling-static-methods
[GeneratedRegex(
@"\$\(\s*\[MSBuild\]\s*::\s*(GetPathOfFileAbove|GetDirectoryNameOfFileAbove)\s*\(",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex WalkUpFunctionCallStartRegex();
private static bool ContainsAppHostMarker(XElement root)
{
// Only MSBuild project files declare Aspire markers, so ignore any other well-formed XML whose root
// is not <Project>. Compare on Name.LocalName so projects using the legacy MSBuild XML namespace
// (xmlns="http://schemas.microsoft.com/developer/msbuild/2003") are still recognized.
if (!root.Name.LocalName.Equals("Project", StringComparison.Ordinal))
{
return false;
}
// 1) SDK-style reference declared via the Sdk attribute on <Project>, which may list multiple
// SDKs with optional versions, e.g.:
// <Project Sdk="Microsoft.NET.Sdk;Aspire.AppHost.Sdk/9.0.0">
var sdkAttribute = root.Attribute("Sdk")?.Value;
if (sdkAttribute is not null && ContainsAspireAppHostSdk(sdkAttribute))
{
return true;
}
// The remaining checks compare on Name.LocalName so that projects declaring the legacy MSBuild
// XML namespace (xmlns="http://schemas.microsoft.com/developer/msbuild/2003") are matched the
// same as SDK-style projects that omit it.
// 2) Nested SDK reference element, e.g.:
// <Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />
var hasSdkElement = root.Descendants()
.Any(e => e.Name.LocalName.Equals("Sdk", StringComparison.Ordinal)
&& string.Equals(e.Attribute("Name")?.Value, AspireAppHostSdkName, StringComparison.OrdinalIgnoreCase));
if (hasSdkElement)
{
return true;
}
// 3) <Import> form of an SDK reference, e.g.:
// <Import Project="Sdk.props" Sdk="Aspire.AppHost.Sdk" Version="9.0.0" />
// <Import Project="Sdk.targets" Sdk="Aspire.AppHost.Sdk" />
// This is functionally equivalent to the previous two forms — it lets a project import an SDK's
// Sdk.props/Sdk.targets at a specific point in the file. Missing it means an AppHost using this
// form is silently rejected by the cheap pre-check. See:
// https://learn.microsoft.com/visualstudio/msbuild/how-to-use-project-sdk#import-an-sdk-into-your-project
var hasImportSdk = root.Descendants()
.Any(e => e.Name.LocalName.Equals("Import", StringComparison.Ordinal)
&& string.Equals(e.Attribute("Sdk")?.Value, AspireAppHostSdkName, StringComparison.OrdinalIgnoreCase));
if (hasImportSdk)
{
return true;
}
// 4) Explicit <IsAspireHost>true</IsAspireHost> property element. The Aspire.AppHost.Sdk sets this
// during evaluation, but it can also appear literally in a project or build file. Matching on the
// element (rather than a substring) means a consumer condition such as
// Condition="'$(IsAspireHost)' == 'true'" is correctly not treated as a marker.
//
// Match the element name case-insensitively because MSBuild property names are themselves
// case-insensitive — `<isaspirehost>true</isaspirehost>` sets `$(IsAspireHost)` to `true` at
// evaluation time just as the PascalCase form does, so a case-sensitive comparison here would
// silently reject a real AppHost using lower- or mixed-case marker syntax.
// Docs: https://learn.microsoft.com/visualstudio/msbuild/msbuild-properties
return root.Descendants()
.Any(e => e.Name.LocalName.Equals(IsAspireHostProperty, StringComparison.OrdinalIgnoreCase)
&& string.Equals(e.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase));
}
private static bool MatchesAppHostNameHeuristics(FileInfo projectFile)
{
// Convention 1: the project file is named like an AppHost, e.g. "MyApp.AppHost.csproj" or
// "AppHost.csproj". Compare on the name without extension so both "Foo.AppHost" and "AppHost" match.
if (Path.GetFileNameWithoutExtension(projectFile.Name).EndsWith("AppHost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Convention 2: a sibling AppHost.cs source file lives next to the project. Project-based AppHosts
// created by the templates (e.g. `aspire new`) ship an AppHost.cs (PascalCase) with the builder
// entrypoint, so its presence is a strong signal even when the csproj carries no inline marker.
// (The lowercase apphost.cs is the separate single-file AppHost convention, which has no csproj.)
//
// Match the file name case-insensitively by enumerating the directory rather than calling
// File.Exists with a fixed-case name: File.Exists is case-sensitive on Linux/macOS and would miss
// the PascalCase AppHost.cs there. This preserves the behavior of the previous discovery heuristic.
var directory = projectFile.Directory;
return directory is not null
&& directory.EnumerateFiles("*.cs", SearchOption.TopDirectoryOnly)
.Any(file => file.Name.Equals(ProjectAppHostSourceFileName, StringComparison.OrdinalIgnoreCase));
}
private static bool ContainsAspireAppHostSdk(string sdkAttribute)
{
// SDK references resolve through NuGet, whose package IDs are case-insensitive, so a project could
// legitimately write the SDK name in any casing (e.g. "aspire.apphost.sdk") and still build as an
// AppHost. Match case-insensitively so this cheap pre-check agrees with MSBuild rather than wrongly
// skipping a real AppHost over a casing difference.
var sdks = sdkAttribute.Split(';');
foreach (var sdk in sdks)
{
var trimmedSdk = sdk.Trim();
if (trimmedSdk.Equals(AspireAppHostSdkName, StringComparison.OrdinalIgnoreCase) ||
trimmedSdk.StartsWith(AspireAppHostSdkName + "/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
// ═══════════════════════════════════════════════════════════════
// CREATION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public string? AppHostFileName => "apphost.cs";
/// <inheritdoc />
public bool IsUsingProjectReferences(FileInfo appHostFile)
{
return false;
}
// ═══════════════════════════════════════════════════════════════
// EXECUTION
// ═══════════════════════════════════════════════════════════════
/// <inheritdoc />
public async Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken)
{
if (IsUnsupported)
{
return new AppHostValidationResult(IsValid: false, IsUnsupported: true);
}
var isSingleFile = appHostFile.Extension.Equals(".cs", StringComparison.OrdinalIgnoreCase);
if (isSingleFile)
{
// For single-file apphosts, validate that:
// 1. No sibling .csproj files exist (otherwise it's part of a project)
// 2. The file contains the #:sdk Aspire.AppHost.Sdk directive
return new AppHostValidationResult(IsValid: IsValidSingleFileAppHost(appHostFile));
}
// Fast path that mitigates the MSBuild "evaluation storm": cheaply reject project-file
// candidates that are not likely AppHosts before paying for MSBuild evaluation below.
if (!IsLikelyAppHost(appHostFile))
{
return new AppHostValidationResult(IsValid: false);
}
// The resolver owns the cache/MSBuild fallback so validation and later run/publish
// decisions share a single source of truth for AppHost project metadata.
var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken);
if (information.ExitCode == 0 && information.IsAspireHost)
{
return new AppHostValidationResult(IsValid: true, AspireHostingVersion: information.AspireHostingVersion);
}
// MSBuild evaluated the project cleanly (exit code 0) but it is not an Aspire host. That is an
// authoritative "no": for example a Microsoft.NET.Sdk.Web project that merely sits next to an
// apphost.cs and so passed the name heuristic above. Reject it quietly rather than surfacing a
// spurious possibly-unbuildable warning for a project that evaluates fine and simply isn't an AppHost.
if (information.ExitCode == 0)
{
return new AppHostValidationResult(IsValid: false);
}
// MSBuild failed to evaluate the project (non-zero exit). The cheap classifier judged it a likely
// AppHost (an inline/co-located marker or the name heuristic), so surface it as a possibly-unbuildable
// AppHost (kept as a candidate with a warning) rather than silently discarding what may be a real
// AppHost that currently fails to build.
return new AppHostValidationResult(
IsValid: false,
IsPossiblyUnbuildable: true);
}
/// <inheritdoc />
public async Task<string?> GetAspireHostingVersionAsync(FileInfo appHostFile, CancellationToken cancellationToken)
{
// Use the same MSBuild-based inspection as validation so version resolution
// follows the project model that run/publish already rely on, including
// SDK-style projects, package references, and Central Package Management.
var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken);
return information.ExitCode == 0 && information.IsAspireHost
? information.AspireHostingVersion
: null;
}
/// <inheritdoc />
public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken cancellationToken)
{
// .NET projects require the SDK to be installed
if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, _interactionService, _telemetry, cancellationToken: cancellationToken))
{
// Signal build failure so RunCommand doesn't wait forever
context.BuildCompletionSource?.TrySetResult(false);
return CliExitCodes.SdkNotInstalled;
}
var effectiveAppHostFile = context.AppHostFile;
var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out var extensionBackchannel);
var buildOutputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.Build);
using var activity = _profilingTelemetry.StartAppHostRun();
var isSingleFileAppHost = !IsProjectFile(effectiveAppHostFile);
var env = new Dictionary<string, string>(context.EnvironmentVariables);
// Handle isolated mode - randomize ports and isolate user secrets
string? isolatedUserSecretsId = null;
if (context.Isolated)
{
using var isolatedModeActivity = _profilingTelemetry.StartAppHostConfigureIsolatedMode();
try
{
isolatedUserSecretsId = await ConfigureIsolatedModeAsync(effectiveAppHostFile, env, cancellationToken);
_logger.LogInformation("Aspire run isolated. Isolated UserSecretsId: {IsolatedUserSecretsId}", isolatedUserSecretsId);
}
catch (Exception ex)
{
isolatedModeActivity.SetError(ex.Message);
throw;
}
}
// Enable debug logging in the app host so that debug-level output is
// captured in the CLI log file for diagnostics. Defaults to Debug but
// can be overridden via --log-level.
var aspireLogLevel = _loggingOptions.ConsoleLogLevel ?? LogLevel.Debug;
env[KnownConfigNames.AspireLogLevel] = aspireLogLevel.ToString();
if (context.WaitForDebugger)
{
env[KnownConfigNames.WaitForDebugger] = "true";
}
await EnsureDevCertificatesTrustedAsync(context, env, cancellationToken);
var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken);
using var cliBundleLeaseScope = cliBundleLease;
ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard: false);
var watch = !isSingleFileAppHost && _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false);
var preparationExitCode = await PrepareAppHostAsync(
context,
effectiveAppHostFile,
isSingleFileAppHost,
isExtensionHost,
extensionBackchannel,
buildOutputCollector,
cancellationToken);
if (preparationExitCode is { } exitCode)
{
return exitCode;
}
// Two separate bundle interactions:
// - injectDcpAndDashboard: only true when the AppHost opted into AspireUseCliBundle.
// Those env vars would clobber the per-RID NuGet metadata path otherwise.
// - terminal host env vars: always injected when the bundle is available, because
// no per-RID NuGet ships the terminal host today. Skipping ResolveAspireCliBundle
// is fine for non-CliBundle AppHosts that don't use WithTerminal() — the lease
// is best-effort and a missing layout just means no terminal host env vars.
var canQueryCliBundleProperty = !isSingleFileAppHost || !context.NoBuild;
var appHostInfo = canQueryCliBundleProperty
? await _appHostInfoResolver.GetAppHostInfoAsync(effectiveAppHostFile, cancellationToken)
: null;
var injectDcpAndDashboard = appHostInfo?.IsUsingCliBundle == true;
ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard);
// RunCommand may display captured AppHost output as soon as BuildCompletionSource is signaled.
// Store the collector first so failures that occur immediately after preparation are not lost
// to a race between the AppHost process and RunCommand's UX path.
var runOutputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.AppHost);
context.OutputCollector = runOutputCollector;
// Signal that build/preparation is complete
context.BuildCompletionSource?.TrySetResult(true);
activity.AddAppHostBuildReadyEvent();
var runOptions = new ProcessInvocationOptions
{
StandardOutputCallback = runOutputCollector.AppendOutput,
StandardErrorCallback = runOutputCollector.AppendError,
StartDebugSession = context.StartDebugSession,
Debug = context.Debug,
KillEntireProcessTreeOnCancel = ShouldKillEntireProcessTreeOnCancel(_environment.IsWindows()),
// Run path opts into the shared shutdown ladder so pure .NET AppHosts get the
// same graceful-then-tree-kill semantics as TypeScript AppHosts (which already
// route through AppHostServerSession/ProcessGuestLauncher). Build, restore,
// package add, layout, and other short-lived invocations leave these unset so
// they continue to use the shared ladder's force-kill mode.
IsolateConsole = true,
KillOnParentExit = true,
GracefulShutdownSignaler = _gracefulShutdownSignaler,
ShutdownService = _shutdownService,
LaunchProfile = context.LaunchProfile,
};
// The backchannel completion source is the contract with RunCommand
// We signal this when the backchannel is ready, RunCommand uses it for UX
var backchannelCompletionSource = context.BackchannelCompletionSource ?? new TaskCompletionSource<IAppHostCliBackchannel>();
if (isSingleFileAppHost)
{
ConfigureSingleFileRunEnvironment(effectiveAppHostFile, env, args: context.UnmatchedTokens);
}
env[KnownConfigNames.DcpWorkloadId] = AppHostWorkloadId.Create(effectiveAppHostFile);
var directRun = !isSingleFileAppHost && !watch && !isExtensionHost
? await TryCreateDirectRunSpecAsync(effectiveAppHostFile, env, context.UnmatchedTokens, runOptions.NoLaunchProfile, runOptions.LaunchProfile, cancellationToken)
: null;
// Start the apphost - the runner will signal the backchannel when ready
try
{
// The AppHost may already have been built above, but watch mode intentionally still
// runs with builds enabled. Passing --no-build through to dotnet watch breaks hot reload
// because watch owns the incremental build loop and its environment setup.
//
// This means watch mode can do a second no-op build after the CLI pre-build succeeds.
// That tradeoff is intentional: the pre-build makes initial compiler errors terminate
// aspire run instead of leaving dotnet watch idle waiting for edits before a backchannel
// ever becomes available.
//
// noRestore is only relevant when noBuild is false because --no-build implies --no-restore.
var noBuild = !watch || context.NoBuild;
using var runDotnetActivity = _profilingTelemetry.StartAppHostRunDotnetLifetime(watch, noBuild, context.NoRestore);
if (directRun is not null)
{
// The direct command line has no "--" separator, so the forwarded-argument boundary
// has to be carried alongside it for logging. Clone rather than mutate because the
// caller may reuse runOptions for other invocations.
var directRunOptions = runOptions.Clone();
directRunOptions.AppHostArgumentStartIndex = directRun.AppHostArgumentStartIndex;
return await _runner.RunAppHostCommandAsync(
effectiveAppHostFile,
directRun.Command,
directRun.WorkingDirectory,
directRun.Arguments,
directRun.Environment,
backchannelCompletionSource,
directRunOptions,
cancellationToken);
}
return await _runner.RunAsync(
effectiveAppHostFile,
watch,
noBuild,
context.NoRestore,
context.UnmatchedTokens,
env,
backchannelCompletionSource,
runOptions,
cancellationToken);
}
finally
{
// Clean up isolated user secrets when the run completes
if (!string.IsNullOrEmpty(isolatedUserSecretsId))
{
IsolatedUserSecretsHelper.CleanupIsolatedUserSecrets(isolatedUserSecretsId);
}
}
}
internal static bool ShouldKillEntireProcessTreeOnCancel(bool isWindows) => !isWindows;
private async Task EnsureDevCertificatesTrustedAsync(AppHostProjectContext context, Dictionary<string, string> env, CancellationToken cancellationToken)
{
try
{
EnsureCertificatesTrustedResult certResult;
using (var certActivity = _profilingTelemetry.StartAppHostEnsureDevCertificates())
{
certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken);
certActivity.SetDevCertificateEnvironmentVariables(certResult.EnvironmentVariables.Count);
}
// Certificate trust can add platform-specific variables such as SSL_CERT_DIR on Linux.
// These must flow into the AppHost process because the dashboard/resource service may
// start immediately after preparation and depend on the same trust roots the CLI just
// verified.
foreach (var kvp in certResult.EnvironmentVariables)
{
env[kvp.Key] = kvp.Value;
}
}
catch
{
// RunCommand waits on this source before it waits for the AppHost backchannel. Any
// exception during preparation must signal failure, otherwise the command can hang
// forever on a backchannel that will never be created.
context.BuildCompletionSource?.TrySetResult(false);
throw;
}
}
private async Task<int?> PrepareAppHostAsync(
AppHostProjectContext context,
FileInfo effectiveAppHostFile,
bool isSingleFileAppHost,
bool isExtensionHost,
IExtensionBackchannel? extensionBackchannel,
OutputCollector buildOutputCollector,
CancellationToken cancellationToken)
{
try
{
var buildExitCode = await BuildAppHostIfNeededAsync(
context,
effectiveAppHostFile,
isExtensionHost,
extensionBackchannel,
buildOutputCollector,
cancellationToken);
if (buildExitCode is not null)
{
return buildExitCode;
}
var compatibilityCheck = await CheckAppHostCompatibilityAsync(effectiveAppHostFile, isSingleFileAppHost, cancellationToken);
if (!compatibilityCheck.IsCompatibleAppHost)
{
context.BuildCompletionSource?.TrySetResult(false);
return CliExitCodes.FailedToDotnetRunAppHost;
}
return null;
}
catch
{
// RunCommand has already started awaiting preparation before the AppHost process exists.
// Signal failure for both expected failures and exceptions so callers do not wait for
// a backchannel that preparation prevented from starting.
context.BuildCompletionSource?.TrySetResult(false);
throw;
}
}
private async Task<int?> BuildAppHostIfNeededAsync(
AppHostProjectContext context,
FileInfo effectiveAppHostFile,
bool isExtensionHost,
IExtensionBackchannel? extensionBackchannel,
OutputCollector buildOutputCollector,
CancellationToken cancellationToken)
{
if (context.NoBuild)
{
return null;
}
var extensionHasBuildCapability = extensionBackchannel is not null && await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.BuildDotnetUsingCli, cancellationToken);
if (isExtensionHost && !extensionHasBuildCapability)
{
// Older extension hosts own the AppHost build themselves. Building again in the CLI would
// duplicate work and could race the extension's diagnostics/launch pipeline. Newer hosts
// opt in with build-dotnet-using-cli when they want the CLI to own this pre-build.
return null;
}
using var buildActivity = _profilingTelemetry.StartAppHostBuild(context.NoRestore, isExtensionHost, extensionHasBuildCapability);
var buildOptions = new ProcessInvocationOptions
{
StandardOutputCallback = buildOutputCollector.AppendOutput,
StandardErrorCallback = buildOutputCollector.AppendError,
};
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostFile, context.NoRestore, buildOptions, context.WorkingDirectory, cancellationToken);
buildActivity.SetAppHostBuildExitCode(buildExitCode);
if (buildExitCode == 0)
{
return null;
}
// Preserve the build output before signaling failure. RunCommand reads this collector after
// BuildCompletionSource completes so users see the compiler diagnostics instead of only a
// generic "project could not be built" message.
context.OutputCollector = buildOutputCollector;
context.BuildCompletionSource?.TrySetResult(false);
return CliExitCodes.FailedToBuildArtifacts;
}
private async Task<(bool IsCompatibleAppHost, string? AspireHostingVersion)> CheckAppHostCompatibilityAsync(
FileInfo effectiveAppHostFile,
bool isSingleFileAppHost,
CancellationToken cancellationToken)
{
if (isSingleFileAppHost)
{
// A single-file apphost pins its Aspire.Hosting version via the
// `#:sdk Aspire.AppHost.Sdk@<version>` directive, which uses IdentitySdkVersion (the
// identity version with build metadata stripped, matching the published NuGet package
// version). Report that same value here so the compatibility check reflects what the
// apphost actually pins, honoring ASPIRE_CLI_VERSION / sidecar overrides rather than
// the physical assembly version.
return (true, _executionContext.IdentitySdkVersion);
}
using var compatibilityActivity = _profilingTelemetry.StartAppHostCheckCompatibility();
// Reuse the cached MSBuild result from ValidateAppHostAsync so we do not pay for a
// second `dotnet msbuild -getProperty/-getItem` invocation just to gate compatibility.
// Issue #17197: the legacy code path went runner → MSBuild for both validation and
// the compatibility gate, doubling project inspection cost on every `aspire run`.
var info = await _appHostInfoResolver.GetAppHostInfoAsync(effectiveAppHostFile, cancellationToken);
var appHostCompatibilityCheck = AppHostHelper.EvaluateAppHostCompatibility(
info.ExitCode,
info.IsAspireHost,
info.AspireHostingVersion,
_interactionService,
_fileLoggerProvider.LogFilePath);
compatibilityActivity.SetAppHostCompatibility(
appHostCompatibilityCheck.IsCompatibleAppHost,
supportsBackchannel: appHostCompatibilityCheck.IsCompatibleAppHost,
appHostCompatibilityCheck.AspireHostingVersion);
return appHostCompatibilityCheck;
}
private async Task<DirectAppHostRunSpec?> TryCreateDirectRunSpecAsync(
FileInfo effectiveAppHostFile,
Dictionary<string, string> env,
string[] unmatchedTokens,
bool noLaunchProfile,
string? launchProfile,
CancellationToken cancellationToken)
{
if (await IsDirectLaunchDisabledAsync(effectiveAppHostFile, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug("Falling back to dotnet run for {Project}; direct AppHost launch is disabled by configuration.", effectiveAppHostFile.FullName);
return null;
}
// Direct launch intentionally uses the same cached AppHost inspection as validation. The
// disk cache fingerprint includes the project file and conventional imported build files
// (Directory.Build.*, Directory.Packages.*, global.json, and project.assets.json), so edits
// that change AssemblyName/OutputPath/UseAppHost through those inputs force a fresh
// ComputeRunArguments probe before RunCommand is used. If a project relies on custom
// imports outside that tracked set, the cache can be disabled with
// dotnetAppHostInfoCacheDisabled rather than paying an extra MSBuild evaluation on every run.
var info = await _appHostInfoResolver.GetAppHostInfoAsync(effectiveAppHostFile, cancellationToken).ConfigureAwait(false);
var arguments = ParseArguments(info.RunArguments);
var hasRunArguments = arguments.Count > 0;
if (!TryResolveDirectRunTarget(info, effectiveAppHostFile, arguments, out var command, out var workingDirectory))
{
return null;
}
var directEnv = new Dictionary<string, string>();
if (!TryApplyProjectLaunchSettings(
effectiveAppHostFile,
directEnv,
arguments,
noLaunchProfile,
launchProfile,
hasExplicitApplicationArgs: unmatchedTokens.Length > 0,
hasRunArguments))
{
return null;
}
foreach (var (name, value) in env)
{
directEnv[name] = value;
}
arguments.AddRange(unmatchedTokens);
// Everything before this index came from MSBuild RunArguments or the launch profile; the
// tail is user-supplied AppHost input that can carry connection strings and API keys.
var appHostArgumentStartIndex = arguments.Count - unmatchedTokens.Length;
_logger.LogDebug(
"Launching AppHost directly via {Command} in {WorkingDirectory} with arguments {Arguments}.",
command,
workingDirectory.FullName,
AppHostArgumentRedactor.RedactFromToString(arguments, appHostArgumentStartIndex));
return new DirectAppHostRunSpec(command, workingDirectory, [.. arguments], directEnv, appHostArgumentStartIndex);
}
private async Task<bool> IsDirectLaunchDisabledAsync(FileInfo effectiveAppHostFile, CancellationToken cancellationToken)
{
var startDirectory = effectiveAppHostFile.Directory ?? new DirectoryInfo(Environment.CurrentDirectory);
var value = await _configurationService.GetConfigurationFromDirectoryAsync(DirectLaunchDisabledConfigKey, startDirectory, cancellationToken: cancellationToken).ConfigureAwait(false);
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private bool TryResolveDirectRunTarget(
AppHostProjectInfo info,
FileInfo effectiveAppHostFile,
IReadOnlyList<string> runArguments,
out string command,
out DirectoryInfo workingDirectory)
{
command = null!;
workingDirectory = effectiveAppHostFile.Directory!;
if (HasMultipleTargetFrameworks(info))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; direct AppHost launch does not support multi-targeted projects ({TargetFrameworks}).",
effectiveAppHostFile.FullName,
info.TargetFrameworks);
return false;
}
if (string.IsNullOrWhiteSpace(info.RunCommand))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; MSBuild did not provide RunCommand.",
effectiveAppHostFile.FullName);
return false;
}
var projectDirectory = effectiveAppHostFile.Directory!;
var runCommand = CommandPathResolver.NormalizeRunCommand(info.RunCommand);
// The SDK emits RunCommand="dotnet" for executable .NETCoreApp projects without an apphost,
// with RunArguments shaped as:
// exec "<TargetPath>" [StartArguments...]
// Treat that as a direct-launchable SDK run command instead of looking for a literal
// "dotnet" executable next to the project. DotNetCliRunner later substitutes Aspire's
// resolved dotnet muxer so private SDK selection stays consistent.
// https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets
if (IsDotNetMuxerCommand(runCommand))
{
if (runArguments.Count < 2 || !string.Equals(runArguments[0], "exec", StringComparison.Ordinal))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; RunCommand uses dotnet but RunArguments do not start with 'exec'.",
effectiveAppHostFile.FullName);
return false;
}
var resolvedTargetPath = ResolvePath(runArguments[1], projectDirectory);
if (!File.Exists(resolvedTargetPath))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; RunArguments target {TargetPath} does not exist.",
effectiveAppHostFile.FullName,
resolvedTargetPath);
return false;
}
var runtimeConfigPath = Path.ChangeExtension(resolvedTargetPath, ".runtimeconfig.json");
if (!File.Exists(runtimeConfigPath))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; runtimeconfig {RuntimeConfigPath} does not exist.",
effectiveAppHostFile.FullName,
runtimeConfigPath);
return false;
}
command = runCommand;
}
else
{
var resolvedRunCommand = ResolvePath(runCommand, projectDirectory);
if (!File.Exists(resolvedRunCommand))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; RunCommand {RunCommand} does not exist.",
effectiveAppHostFile.FullName,
resolvedRunCommand);
return false;
}
command = resolvedRunCommand;
}
if (!string.IsNullOrWhiteSpace(info.RunWorkingDirectory))
{
workingDirectory = new DirectoryInfo(ResolvePath(info.RunWorkingDirectory, projectDirectory));
}
return true;
}
private static bool HasMultipleTargetFrameworks(AppHostProjectInfo info)
=> info.TargetFrameworks?.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length > 1;
private static string ResolvePath(string path, DirectoryInfo baseDirectory)
=> Path.IsPathFullyQualified(path) ? path : Path.GetFullPath(Path.Combine(baseDirectory.FullName, path));
private static bool IsDotNetMuxerCommand(string command)
=> string.Equals(Path.GetFileNameWithoutExtension(CommandPathResolver.NormalizeRunCommand(command)), "dotnet", StringComparison.OrdinalIgnoreCase);
private bool TryApplyProjectLaunchSettings(
FileInfo effectiveAppHostFile,
Dictionary<string, string> env,
List<string> arguments,
bool noLaunchProfile,
string? launchProfile,
bool hasExplicitApplicationArgs,
bool hasRunArguments)
{
if (noLaunchProfile)
{
return true;
}
try
{
if (!TryGetLaunchSettingsPath(effectiveAppHostFile, out var launchSettingsPath))
{
// An explicitly selected profile must not be silently ignored. Let the SDK path
// remain authoritative for its missing launch-settings/profile diagnostic.
return string.IsNullOrEmpty(launchProfile);
}
if (!TryGetLaunchProfile(launchSettingsPath, launchProfile, out var profileName, out var profile))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; launch settings do not contain the requested or a supported default profile.",
effectiveAppHostFile.FullName);
return false;
}
if (!IsProjectLaunchProfile(profile))
{
_logger.LogDebug(
"Falling back to dotnet run for {Project}; launch profile {LaunchProfile} uses commandName {CommandName}.",
effectiveAppHostFile.FullName,
profileName,
profile.CommandName);
return false;
}
// Project launchSettings.json uses the .NET launch profile shape:
// { "profiles": { "https": { "commandName": "Project",
// "applicationUrl": "https://localhost:1234;http://localhost:5678",
// "commandLineArgs": "--flag \"two words\"",
// "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" } } } }
// `dotnet run` selects the first supported profile in file order when no profile is
// explicitly named. Direct launch can only preserve Project profiles, so Executable
// profiles fall back to the SDK command path.
// See https://learn.microsoft.com/aspnet/core/fundamentals/environments#lsj and
// https://json.schemastore.org/launchsettings.json.
env["DOTNET_LAUNCH_PROFILE"] = profileName;
if (!string.IsNullOrWhiteSpace(profile.ApplicationUrl))
{
env[KnownAspNetCoreConfigNames.Urls] = profile.ApplicationUrl;
}
if (profile.EnvironmentVariables is not null)
{
foreach (var (name, value) in profile.EnvironmentVariables)
{
if (value is null)
{
// `System.Text.Json` will deserialize `"FOO": null` into a null dictionary
// value even though the value type is non-nullable. Skip those entries.
continue;
}
// Match Aspire project-resource launch-profile behavior rather than `dotnet run`:
// Aspire expands environment-variable references before starting child resources.
env[name] = Environment.ExpandEnvironmentVariables(value);
}
}
if (!hasExplicitApplicationArgs && !hasRunArguments && !string.IsNullOrEmpty(profile.CommandLineArgs))
{
// Keep command-line argument expansion aligned with the environment-variable
// handling above so direct-launch AppHosts behave like Aspire child resources.
AppendParsedArguments(Environment.ExpandEnvironmentVariables(profile.CommandLineArgs), arguments);
}
return true;
}
catch (JsonException ex)
{
_logger.LogDebug(ex, "Falling back to dotnet run because launch settings could not be parsed for {Project}.", effectiveAppHostFile.FullName);
return false;
}
catch (IOException ex)
{
_logger.LogDebug(ex, "Falling back to dotnet run because launch settings could not be read for {Project}.", effectiveAppHostFile.FullName);
return false;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogDebug(ex, "Falling back to dotnet run because launch settings could not be read for {Project}.", effectiveAppHostFile.FullName);
return false;
}
}
private static bool TryGetLaunchSettingsPath(FileInfo projectFile, out string launchSettingsPath)
{
var directory = projectFile.Directory!.FullName;
// Keep this lookup in sync with the SDK's `dotnet run` launch-settings discovery:
// first check Properties/launchSettings.json (or My Project/launchSettings.json for VB),
// then fall back to the flat <ProjectName>.run.json file. Profile parsing intentionally
// stays separate because it must preserve raw JSON property enumeration to match SDK
// duplicate-profile detection.
// https://github.com/dotnet/sdk/blob/main/src/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs
var propertiesDirectoryName = projectFile.Extension.Equals(".vbproj", StringComparison.OrdinalIgnoreCase)
? "My Project"
: "Properties";
var propertiesLaunchSettingsPath = Path.Combine(directory, propertiesDirectoryName, "launchSettings.json");
if (File.Exists(propertiesLaunchSettingsPath))
{
launchSettingsPath = propertiesLaunchSettingsPath;
return true;
}
var runJsonPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(projectFile.Name)}.run.json");
if (File.Exists(runJsonPath))
{
launchSettingsPath = runJsonPath;
return true;
}
launchSettingsPath = null!;
return false;
}
private static bool TryGetLaunchProfile(
string launchSettingsPath,
string? requestedProfileName,
out string profileName,
out AppHostLaunchProfile profile)
{
using var stream = File.OpenRead(launchSettingsPath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
});
if (document.RootElement.ValueKind is not JsonValueKind.Object ||
!document.RootElement.TryGetProperty("profiles", out var profiles) ||
profiles.ValueKind is not JsonValueKind.Object)
{
profileName = null!;
profile = null!;
return false;
}
JsonProperty selectedProfile = default;
if (!string.IsNullOrEmpty(requestedProfileName))
{
var hasMatch = false;
foreach (var candidate in profiles.EnumerateObject())
{
if (!string.Equals(candidate.Name, requestedProfileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// The SDK enumerates raw JSON properties so both duplicate names and names that
// differ only by casing remain visible. Preserve that behavior instead of letting
// dictionary deserialization silently replace an earlier property.
if (hasMatch)
{
profileName = null!;
profile = null!;
return false;
}
selectedProfile = candidate;
hasMatch = true;
}
if (!hasMatch || selectedProfile.Value.ValueKind is not JsonValueKind.Object)
{
profileName = null!;
profile = null!;
return false;
}
}
else
{
foreach (var candidate in profiles.EnumerateObject())
{
if (candidate.Value.ValueKind is not JsonValueKind.Object ||
!candidate.Value.TryGetProperty("commandName", out var commandName) ||
commandName.ValueKind is not JsonValueKind.String ||
commandName.GetString() is not ("Project" or "Executable"))
{
continue;
}
selectedProfile = candidate;
break;
}
if (selectedProfile.Value.ValueKind is not JsonValueKind.Object)
{
profileName = null!;
profile = null!;
return false;
}
}
var selectedProfileValue = selectedProfile.Value.Deserialize(AppHostLaunchSettingsSerializerContext.Default.AppHostLaunchProfile);
if (selectedProfileValue is null)
{
profileName = null!;
profile = null!;
return false;
}
profileName = string.IsNullOrEmpty(requestedProfileName)
? selectedProfile.Name
: requestedProfileName;
profile = selectedProfileValue;
return true;
}
private static bool IsProjectLaunchProfile(AppHostLaunchProfile profile)
=> string.Equals(profile.CommandName, "Project", StringComparison.Ordinal);
private static bool IsProjectFile(FileInfo appHostFile)
=> ProjectExtensions.Contains(appHostFile.Extension.ToLowerInvariant());
private static List<string> ParseArguments(string? rawArguments)
=> string.IsNullOrWhiteSpace(rawArguments)
? []
: CommandLineArgsParser.Parse(rawArguments);
private static void AppendParsedArguments(string? rawArguments, List<string> arguments)
{
if (!string.IsNullOrWhiteSpace(rawArguments))
{
arguments.AddRange(CommandLineArgsParser.Parse(rawArguments));
}
}
internal static void ConfigureSingleFileRunEnvironment(
FileInfo appHostFile,
Dictionary<string, string> env,
IReadOnlyDictionary<string, string?>? inheritedEnvironmentVariables = null,
string[]? args = null)
{
var runJsonFilePath = appHostFile.FullName[..^2] + "run.json";
if (File.Exists(runJsonFilePath))
{
// dotnet run reads the launch profile from apphost.run.json natively, so the CLI
// does not need to inject any environment variables itself.
return;
}
// No apphost.run.json — fall back to aspire.config.json profiles (if any), then to
// hardcoded defaults. ApplyEffectiveEnvironment is always called last so that explicit
// --environment arguments still win.
if (!TryApplyAspireConfigProfile(appHostFile, env, filterEnvironmentNames: false))
{
ApplyDefaultSingleFileEndpoints(env);
}
AppHostEnvironmentDefaults.ApplyEffectiveEnvironment(
env,
AppHostEnvironmentDefaults.DevelopmentEnvironmentName,
inheritedEnvironmentVariables,
args);
}
internal static void ConfigureSingleFilePublishEnvironment(
FileInfo appHostFile,
Dictionary<string, string> env,
IReadOnlyDictionary<string, string?>? inheritedEnvironmentVariables = null,
string[]? args = null)
{
if (!TryApplySingleFileLaunchProfileEnvironmentVariables(appHostFile, env)
&& !TryApplyAspireConfigProfile(appHostFile, env, filterEnvironmentNames: true))
{
ApplyDefaultSingleFileEndpoints(env);
}
AppHostEnvironmentDefaults.ApplyEffectiveEnvironment(
env,
AppHostEnvironmentDefaults.ProductionEnvironmentName,
inheritedEnvironmentVariables,
args);
}
private static bool TryApplySingleFileLaunchProfileEnvironmentVariables(
FileInfo appHostFile,
Dictionary<string, string> env)
{
var profiles = AspireConfigFile.ReadApphostRunProfiles(appHostFile.FullName[..^2] + "run.json");
return TryApplyProfile(profiles, env, filterEnvironmentNames: true);
}
private static bool TryApplyAspireConfigProfile(
FileInfo appHostFile,
Dictionary<string, string> env,
bool filterEnvironmentNames)
{
if (appHostFile.DirectoryName is not { Length: > 0 } directoryName)
{
return false;
}
AspireConfigFile? config;
try
{
config = AspireConfigFile.Load(directoryName);
}
catch (JsonException)
{
// Malformed aspire.config.json — fall back to the next source rather than failing
// the run/publish. This mirrors what happens when apphost.run.json is malformed.
return false;
}
if (config?.Profiles is null)
{
return false;
}
// If aspire.config.json names a different AppHost file, don't apply its profile to
// this AppHost. (Covers layouts where multiple AppHosts share a directory.)
if (!string.IsNullOrEmpty(config.AppHost?.Path))
{
var resolvedAppHostPath = Path.GetFullPath(Path.Combine(directoryName, config.AppHost.Path));
if (!string.Equals(resolvedAppHostPath, appHostFile.FullName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return TryApplyProfile(config.Profiles, env, filterEnvironmentNames);
}
private static bool TryApplyProfile(
IReadOnlyDictionary<string, AspireConfigProfile>? profiles,
Dictionary<string, string> env,
bool filterEnvironmentNames)
{
AspireConfigProfile? profile;
if (profiles?.TryGetValue("https", out var httpsProfile) == true)
{
profile = httpsProfile;
}
else
{
profile = profiles?.Values.FirstOrDefault();
}
if (profile is null || string.IsNullOrEmpty(profile.ApplicationUrl))
{
return false;
}
env[KnownAspNetCoreConfigNames.Urls] = profile.ApplicationUrl;
if (profile.EnvironmentVariables is not null)
{
foreach (var (key, value) in profile.EnvironmentVariables)
{
if (filterEnvironmentNames && AppHostEnvironmentDefaults.IsEnvironmentVariableName(key))
{
continue;
}
env[key] = value;
}
}
return true;
}
private static void ApplyDefaultSingleFileEndpoints(IDictionary<string, string> env)
{
env[KnownAspNetCoreConfigNames.Urls] = "https://localhost:17193;http://localhost:15069";
env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:21293";
env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:22086";
}
/// <inheritdoc />
public async Task<int> PublishAsync(PublishContext context, CancellationToken cancellationToken)
{
// .NET projects require the SDK to be installed
if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, _interactionService, _telemetry, cancellationToken: cancellationToken))
{
// Throw an exception that will be caught by the command and result in SdkNotInstalled exit code
// This is cleaner than trying to signal through the backchannel pattern
throw new DotNetSdkNotInstalledException();
}
var effectiveAppHostFile = context.AppHostFile;
var isSingleFileAppHost = !IsProjectFile(effectiveAppHostFile) && IsValidSingleFileAppHost(effectiveAppHostFile);
var env = new Dictionary<string, string>(context.EnvironmentVariables);
// Check compatibility for project-based apphosts
if (!isSingleFileAppHost)
{
// Route through the cached helper so publish shares the same MSBuild
// inspection result that PublishCommand's earlier ValidateAppHostAsync
// populated. Issue #17197.
var compatibilityCheck = await CheckAppHostCompatibilityAsync(
effectiveAppHostFile,
isSingleFileAppHost: false,
cancellationToken);
if (!compatibilityCheck.IsCompatibleAppHost)
{
var exception = new AppHostIncompatibleException(
$"The app host is not compatible. Aspire.Hosting version: {compatibilityCheck.AspireHostingVersion}",
"Aspire.Hosting",
compatibilityCheck.AspireHostingVersion);
// Signal the backchannel completion source so the caller doesn't wait forever
context.BackchannelCompletionSource?.TrySetException(exception);
throw exception;
}
}
// Build the apphost (unless --no-build is specified)
if (!isSingleFileAppHost && !context.NoBuild)
{
var buildOutputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.Build);
var buildOptions = new ProcessInvocationOptions
{
StandardOutputCallback = buildOutputCollector.AppendOutput,
StandardErrorCallback = buildOutputCollector.AppendError,
};
var buildExitCode = await AppHostHelper.BuildAppHostAsync(
_runner,
_interactionService,
effectiveAppHostFile,
noRestore: false,
buildOptions,
context.WorkingDirectory,
cancellationToken);
if (buildExitCode != 0)
{
// Set OutputCollector so PipelineCommandBase can display errors
context.OutputCollector = buildOutputCollector;
// Signal the backchannel completion source so the caller doesn't wait forever
context.BackchannelCompletionSource?.TrySetException(
new InvalidOperationException("The app host build failed."));
return CliExitCodes.FailedToBuildArtifacts;
}
}
// Create collector and store in context for exception handling
var runOutputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.AppHost);
context.OutputCollector = runOutputCollector;
var runOptions = new ProcessInvocationOptions
{
StandardOutputCallback = runOutputCollector.AppendOutput,
StandardErrorCallback = runOutputCollector.AppendError,
NoLaunchProfile = true,
StartDebugSession = context.StartDebugSession
};
if (isSingleFileAppHost)
{
ConfigureSingleFilePublishEnvironment(effectiveAppHostFile, env, args: context.Arguments);
}
return await _runner.RunAsync(
effectiveAppHostFile,
watch: false,
noBuild: true,
noRestore: false,
context.Arguments,
env,
context.BackchannelCompletionSource,
runOptions,
cancellationToken);
}
/// <inheritdoc />
public async Task<bool> AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken)
{
var outputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.Package);
context.OutputCollector = outputCollector;
var options = new ProcessInvocationOptions
{
StandardOutputCallback = outputCollector.AppendOutput,
StandardErrorCallback = outputCollector.AppendError,
};
var result = await _runner.AddPackageAsync(
context.AppHostFile,
context.PackageId,
context.PackageVersion,
context.Source,
noRestore: false,
options,
cancellationToken);
return result == 0;
}
/// <inheritdoc />
public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken)
{
var result = await _projectUpdater.UpdateProjectAsync(context, cancellationToken);
return new UpdatePackagesResult { UpdatesApplied = result.UpdatedApplied };
}
/// <inheritdoc />
public async Task<RunningInstanceResult> FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken)
{
var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
appHostFile.FullName,
homeDirectory.FullName,
Environment.ProcessId,
_logger);
// Check if any socket files exist
if (matchingSockets.Length == 0)
{
return RunningInstanceResult.NoRunningInstance;
}
// Stop all running instances
var stopTasks = matchingSockets.Select(socketPath =>
_runningInstanceManager.StopRunningInstanceAsync(socketPath, cancellationToken));
var results = await Task.WhenAll(stopTasks);
return results.All(r => r) ? RunningInstanceResult.InstanceStopped : RunningInstanceResult.StopFailed;
}
/// <summary>
/// Gets the UserSecretsId from a project file, optionally initializing if not configured.
/// </summary>
public async Task<string?> GetUserSecretsIdAsync(FileInfo projectFile, bool autoInit, CancellationToken cancellationToken)
{
var userSecretsId = await QueryUserSecretsIdAsync(projectFile, cancellationToken);
if (!string.IsNullOrEmpty(userSecretsId) || !autoInit)
{
return userSecretsId;
}
// Auto-initialize user secrets (only for csproj projects - file-based apphosts
// always have a UserSecretsId provided by the SDK)
if (!ProjectExtensions.Contains(projectFile.Extension.ToLowerInvariant()))
{
return userSecretsId;
}
_logger.LogInformation("No UserSecretsId found. Initializing user secrets for {Project}...", projectFile.Name);
_interactionService.DisplayMessage(KnownEmojis.Key, $"Initializing user secrets for {projectFile.Name}...");
await _runner.InitUserSecretsAsync(
projectFile,
new ProcessInvocationOptions(),
cancellationToken);
// Re-query
return await QueryUserSecretsIdAsync(projectFile, cancellationToken);
}
private async Task<string?> QueryUserSecretsIdAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
try
{
// Read UserSecretsId from the shared AppHost build info cache so isolated mode
// does not pay for a second `dotnet msbuild -getProperty` invocation when the
// run path already fetched the AppHost metadata for validation/compat.
var info = await _appHostInfoResolver.GetAppHostInfoAsync(projectFile, cancellationToken);
return info.UserSecretsId;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get UserSecretsId from project file");
return null;
}
}
private Task<BundleLayoutLease?> AcquireCliBundleLayoutAsync(CancellationToken cancellationToken)
=> _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dotnet-apphost", cancellationToken);
private void ConfigureCliBundleEnvironment(
Dictionary<string, string> env,
BundleLayoutLease? layoutLease,
bool injectDcpAndDashboard)
{
var layout = layoutLease?.Layout;
if (layout is null)
{
// Only log when the AppHost actually opted into the bundle; for non-CliBundle
// AppHosts a missing layout is expected (e.g. the CLI may not have a bundle on
// disk) and would otherwise spam the debug log on every run.
if (injectDcpAndDashboard)
{
_logger.LogDebug("AspireUseCliBundle is enabled, but the Aspire CLI bundle layout was not available from this CLI process. The AppHost will resolve configured, inherited, or assembly-metadata paths.");
}
// Don't return yet — repo-mode runs (DEBUG, `dotnet run --project src/Aspire.Cli`)
// can still inject the terminal host path from the just-built artifact even when
// no bundle layout exists at all (e.g. clean dev machine with no `aspire` install).
}
if (!HasEnvironmentOverride(env, "AspireCliBundlePath") && !string.IsNullOrEmpty(layout?.LayoutPath))
{
env["AspireCliBundlePath"] = layout.LayoutPath;
}
if (injectDcpAndDashboard && layout is not null)
{
if (!IsUsableDcpDirectory(GetEffectiveEnvironmentValue(env, BundleDiscovery.DcpPathEnvVar)) &&
layout.GetDcpPath() is { } layoutDcpPath &&
IsUsableDcpDirectory(layoutDcpPath))
{
env[BundleDiscovery.DcpPathEnvVar] = layoutDcpPath;
}
if (!IsUsableDashboardPath(GetEffectiveEnvironmentValue(env, BundleDiscovery.DashboardPathEnvVar)) &&
layout.GetManagedPath() is { } layoutManagedPath &&
IsUsableDashboardPath(layoutManagedPath))
{
env[BundleDiscovery.DashboardPathEnvVar] = layoutManagedPath;
}
}
// Terminal host injection is unconditional: aspire-managed in the bundle exposes
// the `terminalhost` subcommand regardless of whether the AppHost opted into
// AspireUseCliBundle, and no per-RID NuGet stamps the metadata path today. This
// is what lets `aspire run` light up WithTerminal() for AppHosts created by
// `aspire new` (which default to per-RID NuGets, not the bundle).
//
// Path and args are treated as a pair: if a user pre-populated the path env var
// (e.g. side-loading a custom terminal host build), don't overwrite the args —
// their binary may not understand the "terminalhost" dispatcher arg.
//
// Preference order for the terminal host binary:
// 1) Pre-populated env var — user override always wins.
// 2) Repo-local built artifact when running `dotnet run` inside the Aspire repo
// (DEBUG only — AspireRepositoryDetector walks for Aspire.slnx in DEBUG builds).
// Without this, repo-mode runs pick up the bundle layout cached at the user's
// installed CLI location (e.g. ~/.aspire/bundle/), whose aspire-managed predates
// the `terminalhost` subcommand and fails the AppHost launch with a confusing
// "older CLI" diagnostic. Installed CLIs are unaffected because DetectRepositoryRoot
// only resolves via env var in release builds.
// 3) Bundle layout aspire-managed (normal `aspire run` install path).
if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostPathEnvVar))
{
var terminalHostPath = TryGetRepoLocalManagedPath() ?? layout?.GetManagedPath();
if (terminalHostPath is not null && IsUsableDashboardPath(terminalHostPath))
{
env[BundleDiscovery.TerminalHostPathEnvVar] = terminalHostPath;
if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostInvocationArgsEnvVar))
{
env[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "terminalhost";
}
}
}
layoutLease?.AddEnvironment(env);
}
private bool HasEnvironmentOverride(IReadOnlyDictionary<string, string> env, string name)
=> !string.IsNullOrWhiteSpace(GetEffectiveEnvironmentValue(env, name));
private string? GetEffectiveEnvironmentValue(IReadOnlyDictionary<string, string> env, string name)
=> env.TryGetValue(name, out var value) ? value : _environment.GetEnvironmentVariable(name);
private static bool IsUsableDcpDirectory(string? path)
=> !string.IsNullOrWhiteSpace(path) &&
Directory.Exists(path) &&
File.Exists(BundleDiscovery.GetDcpExecutablePath(path));
private static bool IsUsableDashboardPath(string? path)
=> !string.IsNullOrWhiteSpace(path) && File.Exists(path);
/// <summary>
/// Resolves the repo-local <c>aspire-managed</c> binary when the CLI is running from
/// an Aspire repo checkout (typically <c>dotnet run --project src/Aspire.Cli</c>).
/// Returns <c>null</c> in release builds and when no repo-local build exists.
/// </summary>
private static string? TryGetRepoLocalManagedPath()
{
if (RepoLocalManagedPathProviderOverride is { } overrideProvider)
{
return overrideProvider();
}
var repoRoot = AspireRepositoryDetector.DetectRepositoryRoot();
return BundleDiscovery.TryGetRepoLocalManagedPath(repoRoot);
}
/// <summary>
/// Configures isolated mode by enabling port randomization and isolating user secrets.
/// </summary>
/// <param name="appHostFile">The app host project file.</param>
/// <param name="env">The environment variables dictionary to modify.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The isolated user secrets ID if created, or null if no isolation was needed.</returns>
private async Task<string?> ConfigureIsolatedModeAsync(
FileInfo appHostFile,
Dictionary<string, string> env,
CancellationToken cancellationToken)
{
// Enable port randomization for isolated mode
env["DcpPublisher__RandomizePorts"] = "true";
// Get the UserSecretsId from the project and create isolated copy
var userSecretsId = await QueryUserSecretsIdAsync(appHostFile, cancellationToken);
if (!string.IsNullOrEmpty(userSecretsId))
{
_interactionService.DisplayMessage(KnownEmojis.Key, RunCommandStrings.CopyingUserSecrets);
var isolatedUserSecretsId = IsolatedUserSecretsHelper.CreateIsolatedUserSecrets(userSecretsId);
if (!string.IsNullOrEmpty(isolatedUserSecretsId))
{
// Override the user secrets ID for this run
env["DOTNET_USER_SECRETS_ID"] = isolatedUserSecretsId;
return isolatedUserSecretsId;
}
}
return null;
}
private sealed record DirectAppHostRunSpec(
string Command,
DirectoryInfo WorkingDirectory,
string[] Arguments,
Dictionary<string, string> Environment,
int AppHostArgumentStartIndex);
}