diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
index 0f7169bcf6a..a5b24e22bb4 100644
--- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
+++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
@@ -8,9 +8,9 @@
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
+using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
-
namespace Aspire.Cli.Backchannel;
///
@@ -43,6 +43,7 @@ internal sealed class AppHostConnectionResolver(
IInteractionService interactionService,
IProjectLocator projectLocator,
CliExecutionContext executionContext,
+ ICliHostEnvironment hostEnvironment,
ILogger logger,
ProfilingTelemetry? profilingTelemetry = null)
{
@@ -134,9 +135,18 @@ public async Task ResolveConnectionAsync(
};
}
- var targetPath = projectFile.FullName;
+ // Resolve symlinks before computing the backchannel socket key so the lookup
+ // matches the path the running AppHost used. A running AppHost keys its socket
+ // off Path.GetFullPath(appHostPath) evaluated against its own process working
+ // directory, which the OS already reports in physical (symlink-resolved) form
+ // (getcwd canonicalizes, e.g. /tmp -> /private/tmp on macOS). The producer side
+ // therefore hashes the canonical path, so the consumer must resolve symlinks too.
+ // ResolveToFilesystemPath only fixes Windows casing and is a no-op on Linux/macOS,
+ // so it cannot bridge the /tmp -> /private/tmp gap; ResolveSymlinks does.
+ // See https://github.com/microsoft/aspire/issues/17618.
+ var socketLookupPath = PathNormalizer.ResolveSymlinks(projectFile.FullName);
var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
- targetPath,
+ socketLookupPath,
executionContext.HomeDirectory.FullName,
Environment.ProcessId,
logger);
@@ -161,7 +171,9 @@ public async Task ResolveConnectionAsync(
}
}
- var displayPath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, targetPath);
+ // Display the path the user supplied (not the symlink-resolved lookup path) so the
+ // error message stays relative to the working directory and matches what they typed.
+ var displayPath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName);
return new AppHostConnectionResult
{
@@ -199,6 +211,17 @@ public async Task ResolveConnectionAsync(
}
else if (inScopeConnections.Count > 1)
{
+ if (!hostEnvironment.SupportsInteractiveInput)
+ {
+ // Can't prompt the user to pick an AppHost in non-interactive mode;
+ // fail with an actionable message instead of letting the prompt throw.
+ return new AppHostConnectionResult
+ {
+ ErrorMessage = SharedCommandStrings.MultipleAppHostsNonInteractive,
+ ExitCode = CliExitCodes.FailedToFindProject,
+ };
+ }
+
selectedConnection = await PromptForAppHostSelectionAsync(
inScopeConnections,
SharedCommandStrings.MultipleInScopeAppHosts,
@@ -208,6 +231,18 @@ public async Task ResolveConnectionAsync(
}
else if (outOfScopeConnections.Count > 0)
{
+ if (!hostEnvironment.SupportsInteractiveInput)
+ {
+ // No in-scope AppHosts, and selecting from out-of-scope AppHosts requires
+ // a prompt. In non-interactive mode treat this as "not found" so scripts
+ // get a clean error and exit code instead of an unexpected prompt failure.
+ return new AppHostConnectionResult
+ {
+ ErrorMessage = notFoundMessage,
+ ExitCode = CliExitCodes.FailedToFindProject,
+ };
+ }
+
selectedConnection = await PromptForAppHostSelectionAsync(
outOfScopeConnections,
SharedCommandStrings.NoInScopeAppHostsShowingAll,
diff --git a/src/Aspire.Cli/Commands/CommonCommandServices.cs b/src/Aspire.Cli/Commands/CommonCommandServices.cs
index d6ace2b5688..1386d062127 100644
--- a/src/Aspire.Cli/Commands/CommonCommandServices.cs
+++ b/src/Aspire.Cli/Commands/CommonCommandServices.cs
@@ -16,7 +16,8 @@ internal sealed class CommonCommandServices(
IInteractionService interactionService,
AspireCliTelemetry telemetry,
ConsoleCancellationManager cancellationManager,
- ILoggerFactory loggerFactory)
+ ILoggerFactory loggerFactory,
+ ICliHostEnvironment hostEnvironment)
{
public IFeatures Features { get; } = features;
public ICliUpdateNotifier UpdateNotifier { get; } = updateNotifier;
@@ -25,4 +26,5 @@ internal sealed class CommonCommandServices(
public AspireCliTelemetry Telemetry { get; } = telemetry;
public ConsoleCancellationManager CancellationManager { get; } = cancellationManager;
public ILoggerFactory LoggerFactory { get; } = loggerFactory;
+ public ICliHostEnvironment HostEnvironment { get; } = hostEnvironment;
}
diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs
index 1e485d2bc36..c110493b869 100644
--- a/src/Aspire.Cli/Commands/DescribeCommand.cs
+++ b/src/Aspire.Cli/Commands/DescribeCommand.cs
@@ -111,7 +111,7 @@ public DescribeCommand(
{
Aliases.Add("resources");
_resourceColorMap = resourceColorMap;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs
index 49e3c3ea3d4..a95cca0c695 100644
--- a/src/Aspire.Cli/Commands/ExportCommand.cs
+++ b/src/Aspire.Cli/Commands/ExportCommand.cs
@@ -61,7 +61,7 @@ public ExportCommand(
_httpClientFactory = httpClientFactory;
_timeProvider = timeProvider;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs
index fbfe25065f3..0afbd06be67 100644
--- a/src/Aspire.Cli/Commands/LogsCommand.cs
+++ b/src/Aspire.Cli/Commands/LogsCommand.cs
@@ -128,7 +128,7 @@ public LogsCommand(
_resourceColorMap = resourceColorMap;
_hostEnvironment = hostEnvironment;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger, profilingTelemetry);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger, profilingTelemetry);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/McpCallCommand.cs b/src/Aspire.Cli/Commands/McpCallCommand.cs
index 72a81ef2a78..e56545ddc7d 100644
--- a/src/Aspire.Cli/Commands/McpCallCommand.cs
+++ b/src/Aspire.Cli/Commands/McpCallCommand.cs
@@ -45,7 +45,7 @@ public McpCallCommand(
CommonCommandServices services)
: base("call", McpCommandStrings.CallCommand_Description, services)
{
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Arguments.Add(s_toolArgument);
diff --git a/src/Aspire.Cli/Commands/McpToolsCommand.cs b/src/Aspire.Cli/Commands/McpToolsCommand.cs
index a2dc878021d..ba06f99c87c 100644
--- a/src/Aspire.Cli/Commands/McpToolsCommand.cs
+++ b/src/Aspire.Cli/Commands/McpToolsCommand.cs
@@ -35,7 +35,7 @@ public McpToolsCommand(
CommonCommandServices services)
: base("tools", McpCommandStrings.ToolsCommand_Description, services)
{
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Options.Add(s_appHostOption);
Options.Add(s_formatOption);
diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs
index 56ab577e5c8..e460d689078 100644
--- a/src/Aspire.Cli/Commands/ResourceCommand.cs
+++ b/src/Aspire.Cli/Commands/ResourceCommand.cs
@@ -82,7 +82,7 @@ public ResourceCommand(
{
_backchannelMonitor = backchannelMonitor;
_projectLocator = projectLocator;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
_logger = logger;
Arguments.Add(s_resourceArgument);
diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs
index 3e4fca9766f..c059050a4d0 100644
--- a/src/Aspire.Cli/Commands/StopCommand.cs
+++ b/src/Aspire.Cli/Commands/StopCommand.cs
@@ -44,7 +44,7 @@ public StopCommand(
CommonCommandServices services)
: base("stop", StopCommandStrings.Description, services)
{
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, logger, profilingTelemetry);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger, profilingTelemetry);
_hostEnvironment = hostEnvironment;
_processShutdownService = processShutdownService;
_logger = logger;
diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
index ff479fa5872..37d2ce2e0c0 100644
--- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
@@ -64,7 +64,7 @@ public TelemetryLogsCommand(
_resourceColorMap = resourceColorMap;
_timeProvider = timeProvider;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
index 485bb0186a2..546ff7986ee 100644
--- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
@@ -54,7 +54,7 @@ public TelemetrySpansCommand(
_resourceColorMap = resourceColorMap;
_timeProvider = timeProvider;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
index eeacb139b17..8aa6fddb027 100644
--- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
@@ -54,7 +54,7 @@ public TelemetryTracesCommand(
_resourceColorMap = resourceColorMap;
_timeProvider = timeProvider;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs
index 09cde305f72..cf8cd8adb78 100644
--- a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs
+++ b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs
@@ -66,7 +66,7 @@ public TerminalAttachCommand(
{
_interactionService = services.InteractionService;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
diff --git a/src/Aspire.Cli/Commands/TerminalPsCommand.cs b/src/Aspire.Cli/Commands/TerminalPsCommand.cs
index ec3cd4cd5a8..84b57dd8f66 100644
--- a/src/Aspire.Cli/Commands/TerminalPsCommand.cs
+++ b/src/Aspire.Cli/Commands/TerminalPsCommand.cs
@@ -63,7 +63,7 @@ public TerminalPsCommand(
{
_interactionService = services.InteractionService;
_logger = logger;
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger);
Options.Add(s_appHostOption);
Options.Add(s_formatOption);
diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs
index a49dac9fea5..d74ff101d6c 100644
--- a/src/Aspire.Cli/Commands/WaitCommand.cs
+++ b/src/Aspire.Cli/Commands/WaitCommand.cs
@@ -47,7 +47,7 @@ public WaitCommand(
TimeProvider? timeProvider = null)
: base("wait", WaitCommandStrings.Description, services)
{
- _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, ExecutionContext, logger);
+ _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, ExecutionContext, services.HostEnvironment, logger);
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs
index 2865791a67c..d908221b776 100644
--- a/src/Aspire.Cli/Projects/AppHostServerSession.cs
+++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs
@@ -280,4 +280,18 @@ public async Task CreateAsync(
BuildOutput: prepareResult.Output,
ChannelName: prepareResult.ChannelName);
}
+
+ ///
+ public IAppHostServerSession Start(
+ IAppHostServerProject appHostServerProject,
+ Dictionary? environmentVariables,
+ bool debug)
+ {
+ return AppHostServerSession.Start(
+ appHostServerProject,
+ environmentVariables,
+ debug,
+ _logger,
+ _profilingTelemetry);
+ }
}
diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
index 28d9ce96e0b..fb94b657a4c 100644
--- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs
+++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
@@ -35,6 +35,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen
private readonly IInteractionService _interactionService;
private readonly IAppHostCliBackchannel _backchannel;
private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
+ private readonly IAppHostServerSessionFactory _appHostServerSessionFactory;
private readonly ICertificateService _certificateService;
private readonly IDotNetCliRunner _runner;
private readonly IPackagingService _packagingService;
@@ -57,6 +58,7 @@ public GuestAppHostProject(
IInteractionService interactionService,
IAppHostCliBackchannel backchannel,
IAppHostServerProjectFactory appHostServerProjectFactory,
+ IAppHostServerSessionFactory appHostServerSessionFactory,
ICertificateService certificateService,
IDotNetCliRunner runner,
IPackagingService packagingService,
@@ -73,6 +75,7 @@ public GuestAppHostProject(
_interactionService = interactionService;
_backchannel = backchannel;
_appHostServerProjectFactory = appHostServerProjectFactory;
+ _appHostServerSessionFactory = appHostServerSessionFactory;
_certificateService = certificateService;
_runner = runner;
_packagingService = packagingService;
@@ -283,12 +286,10 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir
}
// Step 2: Start the AppHost server temporarily for code generation
- await using var serverSession = AppHostServerSession.Start(
+ await using var serverSession = _appHostServerSessionFactory.Start(
appHostServerProject,
environmentVariables: null,
- debug: false,
- _logger,
- _profilingTelemetry);
+ debug: false);
// Step 3: Connect to server
var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
@@ -301,6 +302,7 @@ await GenerateCodeViaRpcAsync(
appHostFile: null,
rpcClient,
integrations,
+ targetSdkVersion: config.SdkVersion,
cancellationToken);
// Step 5: Install dependencies using GuestRuntime (best effort - don't block code generation)
@@ -435,16 +437,14 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken
var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false);
// Start the AppHost server process
- AppHostServerSession serverSession;
+ IAppHostServerSession serverSession;
IAppHostRpcClient rpcClient;
using (_profilingTelemetry.StartRunAppHostStartAppHostServer())
{
- serverSession = AppHostServerSession.Start(
+ serverSession = _appHostServerSessionFactory.Start(
appHostServerProject,
launchSettingsEnvVars,
- context.Debug,
- _logger,
- _profilingTelemetry);
+ context.Debug);
try
{
// Step 5: Connect to server for RPC calls. The connection helper retries until
@@ -462,7 +462,7 @@ await GenerateCodeViaRpcAsync(
appHostFile,
rpcClient,
integrations,
- cancellationToken);
+ cancellationToken: cancellationToken);
}
await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
@@ -1014,16 +1014,14 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca
launchSettingsEnvVars[KnownConfigNames.AspireUserSecretsId] = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName);
// Step 2: Start the AppHost server process(it opens the backchannel for progress reporting)
- AppHostServerSession serverSession;
+ IAppHostServerSession serverSession;
IAppHostRpcClient rpcClient;
using (_profilingTelemetry.StartRunAppHostStartAppHostServer())
{
- serverSession = AppHostServerSession.Start(
+ serverSession = _appHostServerSessionFactory.Start(
appHostServerProject,
launchSettingsEnvVars,
- context.Debug,
- _logger,
- _profilingTelemetry);
+ context.Debug);
try
{
@@ -1042,7 +1040,7 @@ await GenerateCodeViaRpcAsync(
appHostFile,
rpcClient,
integrations,
- cancellationToken);
+ cancellationToken: cancellationToken);
}
await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
@@ -1532,7 +1530,8 @@ private async Task GenerateCodeViaRpcAsync(
FileInfo? appHostFile,
IAppHostRpcClient rpcClient,
IEnumerable integrations,
- CancellationToken cancellationToken)
+ string? targetSdkVersion = null,
+ CancellationToken cancellationToken = default)
{
var integrationsList = integrations.ToList();
@@ -1540,7 +1539,7 @@ private async Task GenerateCodeViaRpcAsync(
// The code generator is registered by its Language property, not the runtime ID
var codeGenerator = _resolvedLanguage.CodeGenerator;
- WarnIfCliSdkVersionSkew(appPath);
+ WarnIfCliSdkVersionSkew(appPath, targetSdkVersion);
_logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count);
@@ -1633,10 +1632,19 @@ private bool ShouldEmitLegacyTypeScriptGeneratedFiles(string appPath, FileInfo?
/// purely informational and let code-generation try first so that benign skew (e.g. a
/// daily-build CLI against a stable SDK) doesn't block valid scenarios.
///
- private void WarnIfCliSdkVersionSkew(string appPath)
+ private void WarnIfCliSdkVersionSkew(string appPath, string? targetSdkVersion = null)
{
try
{
+ var cliVersion = _executionContext.IdentitySdkVersion;
+
+ // When the caller is actively updating TO a version that matches the CLI,
+ // the on-disk config is stale and about to be overwritten — skip the warning.
+ if (targetSdkVersion is not null && !IsKnownIncompatibleSkew(cliVersion, targetSdkVersion))
+ {
+ return;
+ }
+
var configDir = ConfigurationHelper.GetConfigRootDirectory(new DirectoryInfo(appPath));
var config = AspireConfigFile.Load(configDir.FullName);
var configuredSdkVersion = config?.SdkVersion;
@@ -1645,7 +1653,6 @@ private void WarnIfCliSdkVersionSkew(string appPath)
return;
}
- var cliVersion = _executionContext.IdentitySdkVersion;
if (!IsKnownIncompatibleSkew(cliVersion, configuredSdkVersion))
{
return;
diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs
index 43ea2344245..08f708b80b6 100644
--- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs
+++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs
@@ -61,6 +61,18 @@ Task CreateAsync(
Dictionary? launchSettingsEnvVars,
bool debug,
CancellationToken cancellationToken);
+
+ ///
+ /// Starts a server session from an already-prepared server project.
+ ///
+ /// The prepared server project to start.
+ /// Optional environment variables for the server process.
+ /// Whether to enable debug logging.
+ /// The started server session.
+ IAppHostServerSession Start(
+ IAppHostServerProject appHostServerProject,
+ Dictionary? environmentVariables,
+ bool debug);
}
///
diff --git a/src/Aspire.Cli/Projects/RunningInstanceManager.cs b/src/Aspire.Cli/Projects/RunningInstanceManager.cs
index 4b5e8b4b9bb..e43ac210f8e 100644
--- a/src/Aspire.Cli/Projects/RunningInstanceManager.cs
+++ b/src/Aspire.Cli/Projects/RunningInstanceManager.cs
@@ -65,6 +65,20 @@ public async Task StopRunningInstanceAsync(string socketPath, Cancellation
if (stopped)
{
_interactionService.DisplaySuccess(RunCommandStrings.RunningInstanceStopped);
+ // Clean up the socket file now that the instance has been stopped.
+ // Leaving it behind would cause subsequent operations to fail when trying to connect to a dead process.
+ try
+ {
+ if (File.Exists(socketPath))
+ {
+ File.Delete(socketPath);
+ _logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore)");
+ }
}
else
{
diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs
index b6dd19c5d01..00f08ab79e5 100644
--- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs
@@ -171,6 +171,15 @@ internal static string MultipleInScopeAppHosts {
}
}
+ ///
+ /// Looks up a localized string similar to Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use..
+ ///
+ internal static string MultipleAppHostsNonInteractive {
+ get {
+ return ResourceManager.GetString("MultipleAppHostsNonInteractive", resourceCulture);
+ }
+ }
+
internal static string PromptRunAgentInit {
get {
return ResourceManager.GetString("PromptRunAgentInit", resourceCulture);
diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx
index ab8f423998e..807cf3dc81d 100644
--- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx
@@ -182,6 +182,9 @@
Multiple running AppHosts found in the current directory. Select from running AppHosts.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
Would you like to configure AI agent environments for this project?
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf
index c8a32b43bd6..f4bb6d65c26 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf
index 5e434376353..062ad0d41b0 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf
index bf202fc6715..7a0d5e50167 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf
index 6ab0951b25a..82e237fe28c 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf
index 9269f235e7e..e0134099555 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf
index 1d67deacc53..c26cf19472d 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf
index e602e9cfa91..e2a514f3ccb 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf
index c7ce26d905e..9b42baa8b8e 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf
index a4077bf74bd..3e11258ddfe 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf
index 092d0677494..4a9433742f0 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf
index a5e5b3b9052..7ca9d8a463c 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf
index 6ed4ab62cb5..d503c3cc7ba 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf
index 0d9c9855ef2..6e810d089ac 100644
--- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf
@@ -82,6 +82,11 @@
The --stream option requires --format json.
+
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+ Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.
+
+ Multiple running AppHosts found in the current directory. Select from running AppHosts.Multiple running AppHosts found in the current directory. Select from running AppHosts.
diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs
index 3b6ed2b2a90..6d07d0638ef 100644
--- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs
+++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs
@@ -8,6 +8,7 @@
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
+using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests.Backchannel;
@@ -35,6 +36,7 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_PreservesFastPa
interactionService,
projectLocator,
executionContext,
+ TestHelpers.CreateInteractiveHostEnvironment(),
NullLogger.Instance);
var result = await resolver.ResolveConnectionAsync(
@@ -58,12 +60,18 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_DeletesDeadPidS
using var workspace = TemporaryWorkspace.Create(outputHelper);
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var projectFile = CreateProjectFile(workspace.WorkspaceRoot, "TestAppHost", "TestAppHost.csproj");
- var socketPath = CreateMatchingSocketFile(projectFile.FullName, workspace.WorkspaceRoot, int.MaxValue - 1);
+ // Key the socket off the symlink-resolved path, matching how a running AppHost computes
+ // its socket id (its working directory is reported physically by the OS). On macOS the
+ // temp workspace lives under /var -> /private/var, so the unresolved and resolved paths
+ // differ and the resolver must resolve symlinks to find this socket.
+ var resolvedProjectPath = PathNormalizer.ResolveSymlinks(projectFile.FullName);
+ var socketPath = CreateMatchingSocketFile(resolvedProjectPath, workspace.WorkspaceRoot, int.MaxValue - 1);
var resolver = new AppHostConnectionResolver(
new TestAuxiliaryBackchannelMonitor(),
new TestInteractionService(),
new TestProjectLocator(),
executionContext,
+ TestHelpers.CreateInteractiveHostEnvironment(),
NullLogger.Instance);
var result = await resolver.ResolveConnectionAsync(
@@ -109,6 +117,7 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndMultipleAppHost
interactionService,
projectLocator,
executionContext,
+ TestHelpers.CreateInteractiveHostEnvironment(),
NullLogger.Instance);
var result = await resolver.ResolveConnectionAsync(
@@ -141,6 +150,7 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndNoAppHosts_Retu
interactionService,
projectLocator,
executionContext,
+ TestHelpers.CreateInteractiveHostEnvironment(),
NullLogger.Instance);
var result = await resolver.ResolveConnectionAsync(
@@ -156,6 +166,118 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndNoAppHosts_Retu
Assert.Equal(InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsNoAppHosts, result.ErrorMessage);
}
+ [Fact]
+ public async Task ResolveConnectionAsync_NonInteractiveWithOnlyOutOfScopeAppHosts_ReturnsNotFoundError()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
+ var monitor = new TestAuxiliaryBackchannelMonitor();
+ monitor.AddConnection("hash1", "socket-other", new TestAppHostAuxiliaryBackchannel { IsInScope = false });
+
+ var resolver = new AppHostConnectionResolver(
+ monitor,
+ new TestInteractionService(),
+ new TestProjectLocator(),
+ executionContext,
+ TestHelpers.CreateNonInteractiveHostEnvironment(),
+ NullLogger.Instance);
+
+ var result = await resolver.ResolveConnectionAsync(
+ projectFile: null,
+ "Scanning",
+ "Select",
+ SharedCommandStrings.AppHostNotRunning,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.True(result.IsProjectResolutionError);
+ Assert.Equal(SharedCommandStrings.AppHostNotRunning, result.ErrorMessage);
+ Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode);
+ }
+
+ [Fact]
+ public async Task ResolveConnectionAsync_NonInteractiveWithMultipleInScopeAppHosts_ReturnsActionableError()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
+ var monitor = new TestAuxiliaryBackchannelMonitor();
+ monitor.AddConnection("hash1", "socket-one", new TestAppHostAuxiliaryBackchannel { IsInScope = true });
+ monitor.AddConnection("hash2", "socket-two", new TestAppHostAuxiliaryBackchannel { IsInScope = true });
+
+ var resolver = new AppHostConnectionResolver(
+ monitor,
+ new TestInteractionService(),
+ new TestProjectLocator(),
+ executionContext,
+ TestHelpers.CreateNonInteractiveHostEnvironment(),
+ NullLogger.Instance);
+
+ var result = await resolver.ResolveConnectionAsync(
+ projectFile: null,
+ "Scanning",
+ "Select",
+ SharedCommandStrings.AppHostNotRunning,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.True(result.IsProjectResolutionError);
+ Assert.Equal(SharedCommandStrings.MultipleAppHostsNonInteractive, result.ErrorMessage);
+ Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode);
+ }
+
+ [Fact]
+ public async Task ResolveConnectionAsync_WithSymlinkedProjectPath_ResolvesToCanonicalSocketKey()
+ {
+ // Regression test for https://github.com/microsoft/aspire/issues/17618.
+ // A running AppHost keys its backchannel socket off the symlink-resolved path
+ // (its process working directory is already physical, e.g. /tmp -> /private/tmp
+ // on macOS). The explicit --apphost lookup must resolve symlinks the same way or
+ // it computes a different appHostId and reports "no running AppHost" even though
+ // one is running. We assert the orphaned socket keyed off the canonical path is
+ // found (and pruned) when the resolver is handed a symlinked project path.
+ Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
+ "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
+
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
+
+ // Real project file under a "real" directory.
+ var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
+ var realProjectFile = new FileInfo(Path.Combine(realDirectory.FullName, "TestAppHost.csproj"));
+ File.WriteAllText(realProjectFile.FullName, "");
+
+ // Symlink "link" -> "real"; the project is addressed through the symlink.
+ var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
+ Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
+ var projectFileViaSymlink = new FileInfo(Path.Combine(symlinkDirectory, "TestAppHost.csproj"));
+
+ // The producer keys its socket off the canonical (symlink-resolved) path, so create
+ // the orphaned socket using that same canonical path with a dead PID.
+ var canonicalPath = PathNormalizer.ResolveSymlinks(projectFileViaSymlink.FullName);
+ var socketPath = CreateMatchingSocketFile(canonicalPath, workspace.WorkspaceRoot, int.MaxValue - 1);
+
+ var resolver = new AppHostConnectionResolver(
+ new TestAuxiliaryBackchannelMonitor(),
+ new TestInteractionService(),
+ new TestProjectLocator(),
+ executionContext,
+ TestHelpers.CreateInteractiveHostEnvironment(),
+ NullLogger.Instance);
+
+ var result = await resolver.ResolveConnectionAsync(
+ projectFileViaSymlink,
+ "Scanning",
+ "Select",
+ SharedCommandStrings.AppHostNotRunning,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ // The socket was located via the symlink-resolved key and pruned because its PID is dead.
+ // Before the fix the resolver hashed the unresolved symlink path, never matched this
+ // socket, and left it on disk.
+ Assert.False(File.Exists(socketPath));
+ }
+
private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
{
return TestExecutionContextHelper.CreateExecutionContext(
diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs
index 42e81e676fe..af5190fcd3a 100644
--- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs
+++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs
@@ -227,7 +227,7 @@ private static bool IsRuntimeOnlyCommand(BaseCommand command)
// Test command implementations
internal sealed class TestCommand : BaseCommand
{
- public TestCommand(string name = "test") : base(name, "Test command", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!))
+ public TestCommand(string name = "test") : base(name, "Test command", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!, null!))
{
}
@@ -239,7 +239,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, Can
internal sealed class TestCommandWithInterface : BaseCommand, IPackageMetaPrefetchingCommand
{
- public TestCommandWithInterface() : base("test-interface", "Test command with interface", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!))
+ public TestCommandWithInterface() : base("test-interface", "Test command with interface", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!, null!))
{
}
diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs
index dc89cd3541d..6da4a947802 100644
--- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs
+++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs
@@ -6,12 +6,14 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
+using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
+using Spectre.Console;
namespace Aspire.Cli.Tests.Projects;
@@ -944,6 +946,194 @@ public void IsUsingProjectReferencesReturnsFalseWhenIdentityIsOverridden()
private GuestAppHostProject CreateGuestAppHostProject()
=> CreateGuestAppHostProject(interactionService: null, identityChannel: "local");
+ ///
+ /// Regression test for https://github.com/microsoft/aspire/issues/18103:
+ /// During aspire update, the code-generation step calls
+ /// WarnIfCliSdkVersionSkew which reads the SDK version from disk. At that
+ /// point the in-memory config has already been updated to the CLI's version, but
+ /// the file hasn't been saved yet. The method should not emit a version-skew warning
+ /// when the update is actively aligning versions.
+ ///
+ ///
+ /// The test drives to demonstrate
+ /// the update scenario (stale on-disk SDK version, update available to match CLI). With
+ /// and
+ /// (which returns empty results from GenerateCodeAsync), the full update flow
+ /// succeeds. The assertion validates that the skew-warning method does not emit a spurious
+ /// warning for the stale on-disk version when the update is aligning versions to the CLI.
+ ///
+ [Fact]
+ public async Task UpdatePackagesAsync_DoesNotEmitStaleVersionSkewWarningDuringUpdate()
+ {
+ var cliVersion = VersionHelper.GetDefaultSdkVersion();
+ var staleVersion = "1.0.0";
+
+ var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
+ await File.WriteAllTextAsync(configPath, $$"""
+ {
+ "sdk": { "version": "{{staleVersion}}" },
+ "packages": { "Aspire.Hosting": "{{staleVersion}}" }
+ }
+ """);
+
+ var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
+ await File.WriteAllTextAsync(appHostPath, "// test apphost");
+
+ // Return the CLI version as the latest available, so aspire update would align them.
+ var fakeCache = new FakeNuGetPackageCache
+ {
+ GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
+ Task.FromResult>(
+ [
+ new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = cliVersion, Source = "test" }
+ ])
+ };
+
+ var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures());
+
+ var interactionService = new TestInteractionService
+ {
+ ConfirmCallback = (_, _) => true
+ };
+
+ var factory = new TestAppHostServerProjectFactory
+ {
+ CreateAsyncCallback = (appPath, _) =>
+ Task.FromResult(new FakeSucceedingAppHostServerProject(appPath))
+ };
+
+ var sessionFactory = new TestAppHostServerSessionFactory();
+
+ var project = CreateGuestAppHostProject(
+ interactionService: interactionService,
+ appHostServerProjectFactory: factory,
+ appHostServerSessionFactory: sessionFactory);
+
+ var context = new UpdatePackagesContext
+ {
+ AppHostFile = new FileInfo(appHostPath),
+ Channel = implicitChannel,
+ ConfirmBinding = PromptBinding.CreateDefault(false),
+ NuGetConfigDirBinding = PromptBinding.CreateDefault(null),
+ };
+
+ // UpdatePackagesAsync will go through BuildAndGenerateSdkAsync → GenerateCodeViaRpcAsync
+ // which calls WarnIfCliSdkVersionSkew reading the stale on-disk config.
+ // It should NOT warn because the update is aligning versions to match the CLI.
+ await project.UpdatePackagesAsync(context, CancellationToken.None);
+
+ Assert.Empty(interactionService.DisplayedErrors);
+ Assert.Collection(interactionService.DisplayedMessages,
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal($"Aspire SDK {staleVersion} to {cliVersion}", Markup.Remove(m.Message));
+ },
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal($"Aspire.Hosting {staleVersion} to {cliVersion}", Markup.Remove(m.Message));
+ },
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message);
+ });
+ }
+
+ ///
+ /// Verifies that WarnIfCliSdkVersionSkew emits the
+ /// when the on-disk SDK version
+ /// genuinely differs from the CLI version and the update target does NOT align them.
+ ///
+ [Fact]
+ public async Task UpdatePackagesAsync_EmitsVersionSkewWarningWhenTargetDiffersFromCli()
+ {
+ var staleVersion = "1.0.0";
+ var updateTargetVersion = "2.0.0"; // Different from CLI version — legitimate skew
+
+ var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
+ await File.WriteAllTextAsync(configPath, $$"""
+ {
+ "sdk": { "version": "{{staleVersion}}" },
+ "packages": { "Aspire.Hosting": "{{staleVersion}}" }
+ }
+ """);
+
+ var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
+ await File.WriteAllTextAsync(appHostPath, "// test apphost");
+
+ // Return a version that does NOT match the CLI version — the skew is genuine.
+ var fakeCache = new FakeNuGetPackageCache
+ {
+ GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
+ Task.FromResult>(
+ [
+ new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = updateTargetVersion, Source = "test" }
+ ])
+ };
+
+ var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures());
+
+ var interactionService = new TestInteractionService
+ {
+ ConfirmCallback = (_, _) => true
+ };
+
+ var factory = new TestAppHostServerProjectFactory
+ {
+ CreateAsyncCallback = (appPath, _) =>
+ Task.FromResult(new FakeSucceedingAppHostServerProject(appPath))
+ };
+
+ var sessionFactory = new TestAppHostServerSessionFactory();
+
+ var project = CreateGuestAppHostProject(
+ interactionService: interactionService,
+ appHostServerProjectFactory: factory,
+ appHostServerSessionFactory: sessionFactory);
+
+ var context = new UpdatePackagesContext
+ {
+ AppHostFile = new FileInfo(appHostPath),
+ Channel = implicitChannel,
+ ConfirmBinding = PromptBinding.CreateDefault(false),
+ NuGetConfigDirBinding = PromptBinding.CreateDefault(null),
+ };
+
+ await project.UpdatePackagesAsync(context, CancellationToken.None);
+
+ var cliVersion = VersionHelper.GetDefaultSdkVersion();
+ var expectedWarning = string.Format(
+ System.Globalization.CultureInfo.CurrentCulture,
+ ErrorStrings.CodegenVersionSkewWarning,
+ cliVersion,
+ staleVersion);
+
+ Assert.Empty(interactionService.DisplayedErrors);
+ Assert.Collection(interactionService.DisplayedMessages,
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal($"Aspire SDK {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message));
+ },
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal($"Aspire.Hosting {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message));
+ },
+ m =>
+ {
+ Assert.Equal("warning", m.Emoji.Name);
+ Assert.Contains(expectedWarning, m.Message);
+ },
+ m =>
+ {
+ Assert.Equal("package", m.Emoji.Name);
+ Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message);
+ });
+ }
+
private string CreateMatchingSocketFile(string appHostPath, int pid)
{
var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
@@ -962,6 +1152,7 @@ private GuestAppHostProject CreateGuestAppHostProject(
TestInteractionService? interactionService = null,
string identityChannel = "local",
TestAppHostServerProjectFactory? appHostServerProjectFactory = null,
+ IAppHostServerSessionFactory? appHostServerSessionFactory = null,
bool identityOverridden = false)
{
var language = new LanguageInfo(
@@ -984,6 +1175,7 @@ private GuestAppHostProject CreateGuestAppHostProject(
interactionService: interactionService ?? new TestInteractionService(),
backchannel: new TestAppHostBackchannel(),
appHostServerProjectFactory: appHostServerProjectFactory ?? new TestAppHostServerProjectFactory(),
+ appHostServerSessionFactory: appHostServerSessionFactory ?? new TestAppHostServerSessionFactory(),
certificateService: new TestCertificateService(),
runner: new TestDotNetCliRunner(),
packagingService: new TestPackagingService(),
diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs
new file mode 100644
index 00000000000..1a5a1caf42e
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Cli.Commands.Sdk;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Utils;
+using Aspire.TypeSystem;
+
+namespace Aspire.Cli.Tests.TestServices;
+
+///
+/// Fake that returns a
+/// without starting a real process or connecting to a socket.
+///
+internal sealed class FakeAppHostServerSession : IAppHostServerSession
+{
+ private readonly FakeAppHostRpcClient _rpcClient = new();
+
+ public string SocketPath => "fake-socket";
+
+ public Process ServerProcess { get; } = Process.GetCurrentProcess();
+
+ public OutputCollector Output { get; } = new();
+
+ public string AuthenticationToken => "fake-token";
+
+ public Task GetRpcClientAsync(CancellationToken cancellationToken)
+ => Task.FromResult(_rpcClient);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+///
+/// Fake RPC client that returns empty results for all operations.
+/// Used to exercise code paths that run after RPC connection without needing a real server.
+///
+internal sealed class FakeAppHostRpcClient : IAppHostRpcClient
+{
+ public Task GetRuntimeSpecAsync(string languageId, CancellationToken cancellationToken)
+ => Task.FromResult(new RuntimeSpec
+ {
+ Language = languageId,
+ DisplayName = "Fake",
+ CodeGenLanguage = "TypeScript",
+ DetectionPatterns = ["apphost.ts"],
+ Execute = new CommandSpec { Command = "node", Args = ["apphost.js"] }
+ });
+
+ public Task> ScaffoldAppHostAsync(string languageId, string targetPath, string? projectName, CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ public Task> GenerateCodeAsync(string languageId, CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public Task> GenerateCodeForAssemblyAsync(string languageId, string assemblyName, CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public Task GetCapabilitiesAsync(CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ public Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs
new file mode 100644
index 00000000000..fafd6481a41
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Utils;
+
+namespace Aspire.Cli.Tests.TestServices;
+
+///
+/// whose returns success.
+/// Used with a fake that bypasses
+/// so is never called.
+///
+internal sealed class FakeSucceedingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject, IDisposable
+{
+ public string AppDirectoryPath { get; } = appDirectoryPath;
+
+ public string GetInstanceIdentifier() => AppDirectoryPath;
+
+ public Task PrepareAsync(
+ string sdkVersion,
+ IEnumerable integrations,
+ string? requestedChannel = null,
+ string? packageSourceOverride = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null));
+
+ public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
+ int hostPid,
+ IReadOnlyDictionary? environmentVariables = null,
+ string[]? additionalArgs = null,
+ bool debug = false) =>
+ throw new NotSupportedException("Run should not be invoked when using a fake session starter.");
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs
index fcaa77b92ef..86d2b9d6d6f 100644
--- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs
+++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs
@@ -8,10 +8,13 @@
namespace Aspire.Cli.Tests.TestServices;
///
-/// Test implementation of that returns a failure result.
+/// Test implementation of that returns a failure result
+/// from and a from .
///
internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFactory
{
+ public Func?, bool, IAppHostServerSession>? StartCallback { get; set; }
+
public Task CreateAsync(
string appHostPath,
string sdkVersion,
@@ -28,4 +31,17 @@ public Task CreateAsync(
BuildOutput: outputCollector,
ChannelName: null));
}
+
+ public IAppHostServerSession Start(
+ IAppHostServerProject appHostServerProject,
+ Dictionary? environmentVariables,
+ bool debug)
+ {
+ if (StartCallback is { } callback)
+ {
+ return callback(appHostServerProject, environmentVariables, debug);
+ }
+
+ return new FakeAppHostServerSession();
+ }
}