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(); + } }