diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 3cc62a0c7ea..6977cfaea52 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -120,7 +120,7 @@ extends: justificationForDisabling: 'see https://portal.microsofticm.com/imp/v3/incidents/incident/482258316/summary' sourceAnalysisPool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows tsa: enabled: true @@ -214,7 +214,7 @@ extends: pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows variables: diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index f2e25aefaa5..dbb6ca7a377 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -63,7 +63,7 @@ extends: sdl: sourceAnalysisPool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows stages: @@ -74,7 +74,7 @@ extends: displayName: 'Validate Release Inputs' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none @@ -109,7 +109,7 @@ extends: displayName: 'Extract BAR Build ID from Build Tags' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none @@ -164,7 +164,7 @@ extends: timeoutInMinutes: 60 pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: self @@ -439,7 +439,7 @@ extends: displayName: 'Print Release Summary' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 9b8faab6a2c..e8d869fd433 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -39,7 +39,7 @@ jobs: pool: ${{ if eq(parameters.agentOs, 'windows') }}: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows ${{ if eq(parameters.agentOs, 'linux') }}: name: NetCore1ESPool-Internal diff --git a/eng/pipelines/templates/public-pipeline-template.yml b/eng/pipelines/templates/public-pipeline-template.yml index ad9e96a7a56..5c394939d03 100644 --- a/eng/pipelines/templates/public-pipeline-template.yml +++ b/eng/pipelines/templates/public-pipeline-template.yml @@ -86,7 +86,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals windows.vs2022preview.amd64.open + demands: ImageOverride -equals 1es-windows-2022-open variables: - name: _buildScript @@ -119,7 +119,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2204.amd64.open + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open variables: - name: _buildScript diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 8f7a8db0414..bf1ec2494b1 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -296,11 +296,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -308,9 +308,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index c2da807d981..bd71628f932 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -300,11 +300,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -312,9 +312,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json index 6f25239fe75..13a7ec9205e 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.2.0-pr.13970.g9fb24263", "packages": { - "Aspire.Hosting.Azure.Storage": "13.2.0-pr.13970.g9fb24263" + "Aspire.Hosting.Azure.Storage": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json index 90e901beee5..780d67e6150 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "local", - "sdkVersion": "13.2.0-preview.1.26081.1", "packages": { - "Aspire.Hosting.RabbitMQ": "13.2.0-preview.1.26081.1" + "Aspire.Hosting.RabbitMQ": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json index 32bf312c1cb..640997ec58b 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.1.0", "packages": { - "Aspire.Hosting.SqlServer": "13.2.0-pr.13970.g0575147c" + "Aspire.Hosting.SqlServer": "" } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..688c6e0525a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -6,6 +6,7 @@ net10.0 enable enable + true false aspire Aspire.Cli diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index a27bdebac36..7a7e1288f69 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -100,8 +100,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell string? configuredChannel = null; if (project.LanguageId != KnownLanguageId.CSharp) { - var settings = AspireJsonConfiguration.Load(effectiveAppHostProjectFile.Directory!.FullName); - configuredChannel = settings?.Channel; + var appHostDirectory = effectiveAppHostProjectFile.Directory!.FullName; + var isProjectReferenceMode = AspireRepositoryDetector.DetectRepositoryRoot(appHostDirectory) is not null; + if (!isProjectReferenceMode) + { + var settings = AspireJsonConfiguration.Load(appHostDirectory); + configuredChannel = settings?.Channel; + } } var packagesWithChannels = await InteractionService.ShowStatusAsync( diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index eef3d1501b7..ef69f774a60 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -45,7 +45,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT { var cacheDirectory = ExecutionContext.CacheDirectory; var filesDeleted = 0; - + // Delete cache files and subdirectories if (cacheDirectory.Exists) { @@ -110,14 +110,13 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT // Also clear the logs directory (skip current process's log file) var logsDirectory = ExecutionContext.LogsDirectory; - // Log files are named cli-{timestamp}-{pid}.log, so we need to check the suffix - var currentLogFileSuffix = $"-{Environment.ProcessId}.log"; + var currentLogFilePath = ExecutionContext.LogFilePath; if (logsDirectory.Exists) { foreach (var file in logsDirectory.GetFiles("*", SearchOption.AllDirectories)) { // Skip the current process's log file to avoid deleting it while in use - if (file.Name.EndsWith(currentLogFileSuffix, StringComparison.OrdinalIgnoreCase)) + if (file.FullName.Equals(currentLogFilePath, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -167,4 +166,4 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT } } } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index e8f202b82d1..3e00efb00c2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -86,6 +87,11 @@ internal sealed class RunCommand : BaseCommand { Description = RunCommandStrings.NoBuildArgumentDescription }; + private static readonly Option s_logFileOption = new("--log-file") + { + Description = "Path to write the log file (used internally by --detach).", + Hidden = true + }; private readonly Option? _startDebugSessionOption; public RunCommand( @@ -126,6 +132,7 @@ public RunCommand( Options.Add(s_formatOption); Options.Add(s_isolatedOption); Options.Add(s_noBuildOption); + Options.Add(s_logFileOption); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -294,9 +301,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Handle remote environments (Codespaces, Remote Containers, SSH) var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null; - var isRemoteContainers = _configuration.GetValue("REMOTE_CONTAINERS", false); - var isSshRemote = _configuration.GetValue("VSCODE_IPC_HOOK_CLI") is not null - && _configuration.GetValue("SSH_CONNECTION") is not null; + var isRemoteContainers = string.Equals(_configuration["REMOTE_CONTAINERS"], "true", StringComparison.OrdinalIgnoreCase); + var isSshRemote = _configuration["VSCODE_IPC_HOOK_CLI"] is not null + && _configuration["SSH_CONNECTION"] is not null; AppendCtrlCMessage(longestLocalizedLengthWithColon); @@ -492,7 +499,7 @@ internal static int RenderAppHostSummary( new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right), new Markup("[dim]N/A[/]")); } - grid.AddRow(Text.Empty, Text.Empty); + grid.AddRow(Text.Empty, Text.Empty); } // Logs row @@ -655,18 +662,23 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first", existingSockets.Length); var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); // Stop all running instances in parallel - don't block on failures - var stopTasks = existingSockets.Select(socket => + var stopTasks = existingSockets.Select(socket => manager.StopRunningInstanceAsync(socket, cancellationToken)); await Task.WhenAll(stopTasks).ConfigureAwait(false); } // Build the arguments for the child CLI process + // Tell the child where to write its log so we can find it on failure. + var childLogFile = GenerateChildLogFilePath(); + var args = new List { "run", "--non-interactive", "--project", - effectiveAppHostFile.FullName + effectiveAppHostFile.FullName, + "--log-file", + childLogFile }; // Pass through global options that should be forwarded to child CLI @@ -707,29 +719,15 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Redirect stdout/stderr to suppress child output - it writes to log file anyway - var startInfo = new ProcessStartInfo - { - FileName = dotnetPath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false, - WorkingDirectory = ExecutionContext.WorkingDirectory.FullName - }; - - // If we're running via `dotnet aspire.dll`, add the DLL as first arg - // When running native AOT, don't add the DLL even if it exists in the same folder + // Build the full argument list for the child process, including the entry assembly + // path when running via `dotnet aspire.dll` + var childArgs = new List(); if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { - startInfo.ArgumentList.Add(entryAssemblyPath); + childArgs.Add(entryAssemblyPath); } - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } + childArgs.AddRange(args); // Start the child process and wait for the backchannel in a single status spinner Process? childProcess = null; @@ -741,30 +739,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? // Failure mode 2: Failed to spawn child process try { - childProcess = Process.Start(startInfo); - if (childProcess is null) - { - return null; - } - - // Start async reading of stdout/stderr to prevent buffer blocking - // Log output for debugging purposes - childProcess.OutputDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stdout: {Line}", e.Data); - } - }; - childProcess.ErrorDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stderr: {Line}", e.Data); - } - }; - childProcess.BeginOutputReadLine(); - childProcess.BeginErrorReadLine(); + childProcess = DetachedProcessLauncher.Start( + dotnetPath, + childArgs, + ExecutionContext.WorkingDirectory.FullName); } catch (Exception ex) { @@ -843,10 +821,8 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? if (childExitedEarly) { - _interactionService.DisplayError(string.Format( - CultureInfo.CurrentCulture, - RunCommandStrings.AppHostExitedWithCode, - childExitCode)); + // Show a friendly message based on well-known exit codes from the child + _interactionService.DisplayError(GetDetachedFailureMessage(childExitCode)); } else { @@ -866,11 +842,11 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? } } - // Always show log file path for troubleshooting + // Point to the child's log file — it contains the actual build/runtime errors _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - _fileLoggerProvider.LogFilePath.EscapeMarkup())); + childLogFile.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -890,7 +866,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? pid, childProcess.Id, dashboardUrls?.BaseUrlWithLoginToken, - _fileLoggerProvider.LogFilePath); + childLogFile); var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); _interactionService.DisplayRawText(json); } @@ -903,7 +879,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? appHostRelativePath, dashboardUrls?.BaseUrlWithLoginToken, codespacesUrl: null, - _fileLoggerProvider.LogFilePath, + childLogFile, isExtensionHost, pid); _ansiConsole.WriteLine(); @@ -913,4 +889,26 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return ExitCodeConstants.Success; } + + internal static string GetDetachedFailureMessage(int childExitCode) + { + return childExitCode switch + { + ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, + _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) + }; + } + + internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log"; + return Path.Combine(logsDirectory, fileName); + } + + private string GenerateChildLogFilePath() + { + return GenerateChildLogFilePath(ExecutionContext.LogsDirectory.FullName, _timeProvider); + } } diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 7427721146e..c75e943e38b 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -146,18 +146,26 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.FailedToFindProject; } - var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + var project = _projectFactory.GetProject(projectFile); + var isProjectReferenceMode = project.IsUsingProjectReferences(projectFile); // Check if channel or quality option was provided (channel takes precedence) var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); PackageChannel channel; + var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + if (!string.IsNullOrEmpty(channelName)) { // Try to find a channel matching the provided channel/quality channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)) ?? throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); } + else if (isProjectReferenceMode) + { + channel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) + ?? allChannels.First(); + } else { // If there are hives (PR build directories), prompt for channel selection. @@ -181,8 +189,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - // Get the appropriate project handler and update packages - var project = _projectFactory.GetProject(projectFile); + // Update packages using the appropriate project handler var updateContext = new UpdatePackagesContext { AppHostFile = projectFile, diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3afeaca8846..3b25cc8785d 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -163,32 +163,56 @@ public bool RemovePackage(string packageId) } /// - /// Gets all package references including the base Aspire.Hosting packages. - /// Uses the SdkVersion for base packages. - /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// Gets the effective SDK version for package-based AppHost preparation. + /// Falls back to when no SDK version is configured. + /// + public string GetEffectiveSdkVersion(string defaultSdkVersion) + { + return string.IsNullOrWhiteSpace(SdkVersion) ? defaultSdkVersion : SdkVersion; + } + + /// + /// Gets all package references including the base Aspire.Hosting package. + /// Empty package versions in settings are resolved to the effective SDK version. /// + /// Default SDK version to use when not configured. /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages() + public IEnumerable<(string Name, string Version)> GetAllPackages(string defaultSdkVersion) { - var sdkVersion = SdkVersion ?? throw new InvalidOperationException("SdkVersion must be set before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); - // Base packages always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) + // Base package always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) yield return ("Aspire.Hosting", sdkVersion); - // Additional packages from settings - if (Packages is not null) + if (Packages is null) + { + yield break; + } + + foreach (var (packageName, version) in Packages) { - foreach (var (packageName, version) in Packages) + // Skip base packages and SDK-only packages + if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || + string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) { - // Skip base packages and SDK-only packages - if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || - string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - yield return (packageName, version); + continue; } + + yield return (packageName, string.IsNullOrWhiteSpace(version) ? sdkVersion : version); } } + + /// + /// Gets all package references including the base Aspire.Hosting packages. + /// Uses the SdkVersion for base packages. + /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// + /// Enumerable of (PackageName, Version) tuples. + public IEnumerable<(string Name, string Version)> GetAllPackages() + { + var sdkVersion = !string.IsNullOrWhiteSpace(SdkVersion) + ? SdkVersion + : throw new InvalidOperationException("SdkVersion must be set to a non-empty value before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + return GetAllPackages(sdkVersion); + } } diff --git a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs index 4035352669d..f833c445359 100644 --- a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs +++ b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs @@ -29,6 +29,22 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// public string LogFilePath => _logFilePath; + /// + /// Generates a unique, chronologically-sortable log file name. + /// + /// The directory where log files will be written. + /// The time provider for timestamp generation. + /// An optional suffix appended before the extension (e.g. "detach-child"). + internal static string GenerateLogFilePath(string logsDirectory, TimeProvider timeProvider, string? suffix = null) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); + var id = Guid.NewGuid().ToString("N")[..8]; + var name = suffix is null + ? $"cli_{timestamp}_{id}.log" + : $"cli_{timestamp}_{id}_{suffix}.log"; + return Path.Combine(logsDirectory, name); + } + /// /// Creates a new FileLoggerProvider that writes to the specified directory. /// @@ -37,10 +53,7 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// Optional console for error messages. Defaults to stderr. public FileLoggerProvider(string logsDirectory, TimeProvider timeProvider, IAnsiConsole? errorConsole = null) { - var pid = Environment.ProcessId; - var timestamp = timeProvider.GetUtcNow().ToString("yyyy-MM-dd-HH-mm-ss", CultureInfo.InvariantCulture); - // Timestamp first so files sort chronologically by name - _logFilePath = Path.Combine(logsDirectory, $"cli-{timestamp}-{pid}.log"); + _logFilePath = GenerateLogFilePath(logsDirectory, timeProvider); try { diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 8152e83ca9c..5bcd3bb2071 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,20 +16,10 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; - public SemVersion? VersionPrefix { get; } = versionPrefix; + public string? PinnedVersion { get; } = pinnedVersion; public string SourceDetails { get; } = ComputeSourceDetails(mappings); - private bool MatchesVersionPrefix(SemVersion semVer) - { - if (VersionPrefix is null) - { - return true; - } - - return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; - } - private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -52,6 +42,11 @@ private static string ComputeSourceDetails(PackageMapping[]? mappings) public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = "Aspire.ProjectTemplates", Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -80,7 +75,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } @@ -115,13 +110,25 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); + + // When pinned to a specific version, override the version on each discovered package + // so the correct version gets installed regardless of what the feed reports as latest. + if (PinnedVersion is not null) + { + return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source }); + } return filteredPackages; } public async Task> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -170,7 +177,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + return packages; } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -181,14 +188,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 7bde39025b0..d1e91bc06d4 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,7 +4,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; -using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -92,13 +91,13 @@ public Task> GetChannelsAsync(CancellationToken canc return null; } - var versionPrefix = GetStagingVersionPrefix(); + var pinnedVersion = GetStagingPinnedVersion(useSharedFeed); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion); return stagingChannel; } @@ -166,20 +165,18 @@ private PackageChannelQuality GetStagingQuality() return PackageChannelQuality.Stable; } - private SemVersion? GetStagingVersionPrefix() + private string? GetStagingPinnedVersion(bool useSharedFeed) { - var versionPrefixValue = configuration["stagingVersionPrefix"]; - if (string.IsNullOrEmpty(versionPrefixValue)) + // Only pin versions when using the shared feed and the config flag is set + var pinToCliVersion = configuration["stagingPinToCliVersion"]; + if (!useSharedFeed || !string.Equals(pinToCliVersion, "true", StringComparison.OrdinalIgnoreCase)) { return null; } - // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison - if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) - { - return semVersion; - } - - return null; + // Get the CLI's own version and strip build metadata (+hash) + var cliVersion = Utils.VersionHelper.GetDefaultTemplateVersion(); + var plusIndex = cliVersion.IndexOf('+'); + return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion; } } diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs new file mode 100644 index 00000000000..ee7620a2fd2 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -0,0 +1,48 @@ +// 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; + +namespace Aspire.Cli.Processes; + +internal static partial class DetachedProcessLauncher +{ + /// + /// Unix implementation using Process.Start with stdio redirection. + /// On Linux/macOS, the redirect pipes' original fds are created with O_CLOEXEC, + /// but dup2 onto fd 0/1/2 clears that flag — so grandchildren DO inherit the pipe + /// as their stdio. However, since we close the parent's read-end immediately, the + /// pipe has no reader and writes produce EPIPE (harmless). The key difference from + /// Windows is that on Unix, only fds 0/1/2 survive exec — no extra handle leakage. + /// + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false, + WorkingDirectory = workingDirectory + }; + + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start detached process"); + + // Close the parent's read-end of the pipes. This means the pipe has no reader, + // so if the grandchild (AppHost) writes to inherited stdout/stderr, it gets EPIPE + // which is harmless. The important thing is no caller is blocked waiting on the + // pipe — unlike Windows where the handle stays open and blocks execSync callers. + process.StandardOutput.Close(); + process.StandardError.Close(); + + return process; + } +} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs new file mode 100644 index 00000000000..da509a0be25 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -0,0 +1,325 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Aspire.Cli.Processes; + +internal static partial class DetachedProcessLauncher +{ + /// + /// Windows implementation using CreateProcess with STARTUPINFOEX and + /// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren. + /// + [SupportedOSPlatform("windows")] + private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory) + { + // Open NUL device for stdout/stderr — child writes go nowhere + using var nulHandle = CreateFileW( + "NUL", + GenericWrite, + FileShareWrite, + nint.Zero, + OpenExisting, + 0, + nint.Zero); + + if (nulHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device"); + } + + // Mark the NUL handle as inheritable (required for STARTUPINFO hStdOutput assignment) + if (!SetHandleInformation(nulHandle, HandleFlagInherit, HandleFlagInherit)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL handle inheritance"); + } + + // Initialize a process thread attribute list with 1 slot (HANDLE_LIST) + var attrListSize = nint.Zero; + InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); + + var attrList = Marshal.AllocHGlobal(attrListSize); + try + { + if (!InitializeProcThreadAttributeList(attrList, 1, 0, ref attrListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process thread attribute list"); + } + + try + { + // Whitelist only the NUL handle for inheritance. + // The grandchild (AppHost) will inherit this harmless handle instead of + // any pipes from the caller's process tree. + var handles = new[] { nulHandle.DangerousGetHandle() }; + var pinnedHandles = GCHandle.Alloc(handles, GCHandleType.Pinned); + try + { + if (!UpdateProcThreadAttribute( + attrList, + 0, + s_procThreadAttributeHandleList, + pinnedHandles.AddrOfPinnedObject(), + (nint)(nint.Size * handles.Length), + nint.Zero, + nint.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to update process thread attribute list"); + } + + var nulRawHandle = nulHandle.DangerousGetHandle(); + + var si = new STARTUPINFOEX(); + si.cb = Marshal.SizeOf(); + si.dwFlags = StartfUseStdHandles; + si.hStdInput = nint.Zero; + si.hStdOutput = nulRawHandle; + si.hStdError = nulRawHandle; + si.lpAttributeList = attrList; + + // Build the command line string: "fileName" arg1 arg2 ... + var commandLine = BuildCommandLine(fileName, arguments); + + var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow; + + if (!CreateProcessW( + null, + commandLine, + nint.Zero, + nint.Zero, + bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited + flags, + nint.Zero, + workingDirectory, + ref si, + out var pi)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); + } + + Process detachedProcess; + try + { + detachedProcess = Process.GetProcessById(pi.dwProcessId); + } + finally + { + // Close the process and thread handles returned by CreateProcess. + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + + return detachedProcess; + } + finally + { + pinnedHandles.Free(); + } + } + finally + { + DeleteProcThreadAttributeList(attrList); + } + } + finally + { + Marshal.FreeHGlobal(attrList); + } + } + + /// + /// Builds a Windows command line string with correct quoting rules. + /// Adapted from dotnet/runtime PasteArguments.AppendArgument. + /// + private static StringBuilder BuildCommandLine(string fileName, IReadOnlyList arguments) + { + var sb = new StringBuilder(); + + // Quote the executable path + sb.Append('"').Append(fileName).Append('"'); + + foreach (var arg in arguments) + { + sb.Append(' '); + AppendArgument(sb, arg); + } + + return sb; + } + + /// + /// Appends a correctly-quoted argument to the command line. + /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs + /// + private static void AppendArgument(StringBuilder sb, string argument) + { + // Windows command-line parsing rules: + // - Backslash is normal except when followed by a quote + // - 2N backslashes + quote → N literal backslashes + unescaped quote + // - 2N+1 backslashes + quote → N literal backslashes + literal quote + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + sb.Append(argument); + return; + } + + sb.Append('"'); + var idx = 0; + while (idx < argument.Length) + { + var c = argument[idx++]; + if (c == '\\') + { + var numBackslash = 1; + while (idx < argument.Length && argument[idx] == '\\') + { + idx++; + numBackslash++; + } + + if (idx == argument.Length) + { + // Trailing backslashes before closing quote — must double them + sb.Append('\\', numBackslash * 2); + } + else if (argument[idx] == '"') + { + // Backslashes followed by quote — double them + escape the quote + sb.Append('\\', numBackslash * 2 + 1); + sb.Append('"'); + idx++; + } + else + { + // Backslashes not followed by quote — emit as-is + sb.Append('\\', numBackslash); + } + + continue; + } + + if (c == '"') + { + sb.Append('\\'); + sb.Append('"'); + continue; + } + + sb.Append(c); + } + + sb.Append('"'); + } + + // --- Constants --- + private const uint GenericWrite = 0x40000000; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint HandleFlagInherit = 0x00000001; + private const uint StartfUseStdHandles = 0x00000100; + private const uint CreateUnicodeEnvironment = 0x00000400; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint CreateNoWindow = 0x08000000; + private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002; + + // --- Structs --- + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public int cb; + public nint lpReserved; + public nint lpDesktop; + public nint lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public nint lpReserved2; + public nint hStdInput; + public nint hStdOutput; + public nint hStdError; + public nint lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public nint hProcess; + public nint hThread; + public int dwProcessId; + public int dwThreadId; + } + + // --- P/Invoke declarations --- + + [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + nint lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + nint hTemplateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetHandleInformation( + SafeFileHandle hObject, + uint dwMask, + uint dwFlags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InitializeProcThreadAttributeList( + nint lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref nint lpSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UpdateProcThreadAttribute( + nint lpAttributeList, + uint dwFlags, + nint attribute, + nint lpValue, + nint cbSize, + nint lpPreviousValue, + nint lpReturnSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial void DeleteProcThreadAttributeList(nint lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +#pragma warning disable CA1838 // CreateProcessW requires a mutable command line buffer + private static extern bool CreateProcessW( + string? lpApplicationName, + StringBuilder lpCommandLine, + nint lpProcessAttributes, + nint lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + nint lpEnvironment, + string? lpCurrentDirectory, + ref STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); +#pragma warning restore CA1838 + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseHandle(nint hObject); +} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs new file mode 100644 index 00000000000..006a4c86025 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -0,0 +1,80 @@ +// 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; + +namespace Aspire.Cli.Processes; + +// ============================================================================ +// DetachedProcessLauncher — Platform-aware child process launcher for --detach +// ============================================================================ +// +// When `aspire run --detach` is used, the CLI spawns a child CLI process which +// in turn spawns the AppHost (the "grandchild"). Two constraints must hold: +// +// 1. The child's stdout/stderr must NOT appear on the parent's console. +// The parent renders its own summary UX (dashboard URL, PID, log path) and +// if the child's output (spinners, "Press CTRL+C", etc.) bleeds through, it +// corrupts the parent's terminal — and breaks E2E tests that pattern-match +// on the parent's output. +// +// 2. No pipe or handle from the parent→child stdio redirection may leak into +// the grandchild (AppHost). If it does, callers that wait for the CLI's +// stdout to close (e.g. Node.js `execSync`, shell `$(...)` substitution) +// will hang until the AppHost exits — which defeats the purpose of --detach. +// +// These two constraints conflict when using .NET's Process.Start: +// +// • RedirectStandardOutput = true → solves (1) but violates (2) on Windows, +// because .NET calls CreateProcess with bInheritHandles=TRUE, and the pipe +// write-handle is duplicated into the child. The child passes it to the +// grandchild (AppHost), keeping the pipe alive. +// +// • RedirectStandardOutput = false → solves (2) but violates (1), because +// the child inherits the parent's console and writes directly to it. +// +// The solution is platform-specific: +// +// ┌─────────┬────────────────────────────────────────────────────────────────┐ +// │ Windows │ P/Invoke CreateProcess with STARTUPINFOEX and an explicit │ +// │ │ PROC_THREAD_ATTRIBUTE_HANDLE_LIST. This lets us set │ +// │ │ bInheritHandles=TRUE (required to assign hStdOutput to NUL) │ +// │ │ while restricting inheritance to ONLY the NUL handle — so the │ +// │ │ grandchild inherits nothing useful. Child stdout/stderr go to │ +// │ │ the NUL device. This is the same approach used by Docker's │ +// │ │ Windows container runtime (microsoft/hcsshim). │ +// │ │ │ +// │ Linux / │ Process.Start with RedirectStandard{Output,Error} = true, │ +// │ macOS │ then immediately close the parent's read-end pipe streams. │ +// │ │ The original pipe fds have O_CLOEXEC, but dup2 onto fd 0/1/2 │ +// │ │ clears it — so grandchildren inherit the pipe as their stdio. │ +// │ │ With no reader, writes produce harmless EPIPE. The critical │ +// │ │ difference from Windows is that no caller gets stuck waiting │ +// │ │ on a pipe handle — closing the read-end is sufficient. │ +// └─────────┴────────────────────────────────────────────────────────────────┘ +// + +/// +/// Launches a child process with stdout/stderr suppressed and no handle/fd +/// inheritance to grandchild processes. Used by aspire run --detach. +/// +internal static partial class DetachedProcessLauncher +{ + /// + /// Starts a detached child process with stdout/stderr going to the null device + /// and no inheritable handles/fds leaking to grandchildren. + /// + /// The executable path (e.g. dotnet or the native CLI). + /// The command-line arguments for the child process. + /// The working directory for the child process. + /// A object representing the launched child. + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory) + { + if (OperatingSystem.IsWindows()) + { + return StartWindows(fileName, arguments, workingDirectory); + } + + return StartUnix(fileName, arguments, workingDirectory); + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e7e1e91cde5..ebc0c3ea7c1 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -92,6 +92,33 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s return (logLevel, debugMode); } + /// + /// Parses --log-file from raw args before the host is built. + /// Used by --detach to tell the child CLI where to write its log. + /// + internal static string? ParseLogFileOption(string[]? args) + { + if (args is null) + { + return null; + } + + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--") + { + break; + } + + if (args[i] == "--log-file" && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + return null; + } + private static string GetGlobalSettingsPath() { var usersAspirePath = GetUsersAspirePath(); @@ -159,7 +186,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // Always register FileLoggerProvider to capture logs to disk // This captures complete CLI session details for diagnostics var logsDirectory = Path.Combine(GetUsersAspirePath(), "logs"); - var fileLoggerProvider = new FileLoggerProvider(logsDirectory, TimeProvider.System); + var logFilePath = ParseLogFileOption(args); + var fileLoggerProvider = logFilePath is not null + ? new FileLoggerProvider(logFilePath) + : new FileLoggerProvider(logsDirectory, TimeProvider.System); builder.Services.AddSingleton(fileLoggerProvider); // Register for direct access to LogFilePath builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(fileLoggerProvider)); @@ -706,4 +736,3 @@ public void Enrich(Profile profile) profile.Capabilities.Interactive = true; } } - diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index df3784ea968..c864bfb0835 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -8,6 +8,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -58,7 +59,7 @@ public async Task CreateAsync(string appPath, Cancellatio } // Priority 1: Check for dev mode (ASPIRE_REPO_ROOT or running from Aspire source repo) - var repoRoot = DetectAspireRepoRoot(); + var repoRoot = AspireRepositoryDetector.DetectRepositoryRoot(appPath); if (repoRoot is not null) { return new DotNetBasedAppHostServerProject( @@ -91,40 +92,4 @@ public async Task CreateAsync(string appPath, Cancellatio "No Aspire AppHost server is available. Ensure the Aspire CLI is installed " + "with a valid bundle layout, or reinstall using 'aspire setup --force'."); } - - /// - /// Detects the Aspire repository root for dev mode. - /// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable - /// looking for a git repo containing Aspire.slnx. - /// - private static string? DetectAspireRepoRoot() - { - // Check explicit environment variable - var envRoot = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT"); - if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) - { - return envRoot; - } - - // Auto-detect: walk up from the CLI executable looking for .git + Aspire.slnx - var cliPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(cliPath)) - { - return null; - } - - var dir = Path.GetDirectoryName(cliPath); - while (dir is not null) - { - if (Directory.Exists(Path.Combine(dir, ".git")) && - File.Exists(Path.Combine(dir, "Aspire.slnx"))) - { - return dir; - } - - dir = Path.GetDirectoryName(dir); - } - - return null; - } } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 7ee32718103..ea56782b2b0 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -139,6 +139,12 @@ private static bool IsValidSingleFileAppHost(FileInfo candidateFile) /// public string? AppHostFileName => "apphost.cs"; + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + // ═══════════════════════════════════════════════════════════════ // EXECUTION // ═══════════════════════════════════════════════════════════════ diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 1ce14fd6e12..4fa1af18f8f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -128,6 +128,12 @@ public bool CanHandle(FileInfo appHostFile) /// public string? AppHostFileName => _resolvedLanguage.DetectionPatterns.FirstOrDefault(); + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return AspireRepositoryDetector.DetectRepositoryRoot(appHostFile.Directory?.FullName) is not null; + } + /// /// Gets all packages including the code generation package for the current language. /// @@ -135,15 +141,28 @@ public bool CanHandle(FileInfo appHostFile) AspireJsonConfiguration config, CancellationToken cancellationToken) { - var packages = config.GetAllPackages().ToList(); + var defaultSdkVersion = GetEffectiveSdkVersion(); + var packages = config.GetAllPackages(defaultSdkVersion).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } return packages; } + private AspireJsonConfiguration LoadConfiguration(DirectoryInfo directory) + { + var effectiveSdkVersion = GetEffectiveSdkVersion(); + return AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + } + + private string GetPrepareSdkVersion(AspireJsonConfiguration config) + { + return config.GetEffectiveSdkVersion(GetEffectiveSdkVersion()); + } + /// /// Prepares the AppHost server (creates files and builds for dev mode, restores packages for prebuilt mode). /// @@ -162,14 +181,14 @@ public bool CanHandle(FileInfo appHostFile) /// private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) { + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); - - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -269,19 +288,19 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken } // Build phase: build AppHost server (dependency install happens after server starts) + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); - - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", async () => { // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -557,14 +576,13 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca try { // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); - var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var config = LoadConfiguration(directory); + var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -802,8 +820,7 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Update .aspire/settings.json with the new package config.AddOrUpdatePackage(context.PackageId, context.PackageVersion); @@ -825,8 +842,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Find updates for SDK version and packages string? newSdkVersion = null; diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index 5a747b65b4a..f2d7b59a1f8 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -166,6 +166,13 @@ internal interface IAppHostProject /// string? AppHostFileName { get; } + /// + /// Determines whether this AppHost should use project references instead of package references. + /// + /// The AppHost file being operated on. + /// when project-reference mode should be used; otherwise . + bool IsUsingProjectReferences(FileInfo appHostFile); + /// /// Runs the AppHost project. /// diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 6d68a96a1a7..43d4f455a8e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -314,7 +314,7 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok if (language is not null && !language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase)) { await configurationService.SetConfigurationAsync("language", language.LanguageId.Value, isGlobal: false, cancellationToken); - + // Inherit SDK version from parent/global config if available var inheritedSdkVersion = await configurationService.GetConfigurationAsync("sdkVersion", cancellationToken); if (!string.IsNullOrEmpty(inheritedSdkVersion)) diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index 5a58b653555..73fd7e4da7f 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -255,6 +255,12 @@ public static string AppHostExitedWithCode { } } + public static string AppHostFailedToBuild { + get { + return ResourceManager.GetString("AppHostFailedToBuild", resourceCulture); + } + } + public static string TimeoutWaitingForAppHost { get { return ResourceManager.GetString("TimeoutWaitingForAppHost", resourceCulture); diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 3b5ca858c10..a5fabb4943d 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -226,6 +226,9 @@ AppHost process exited with code {0}. + + AppHost failed to build. + Timeout waiting for AppHost to start. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 454f6dc920c..5535b7f2e9b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -37,6 +37,11 @@ Proces AppHost byl ukončen s kódem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Hostitel aplikací (AppHost) se úspěšně spustil. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 37a94547020..1278b7bd643 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -37,6 +37,11 @@ Der AppHost-Prozess wurde mit Code {0} beendet. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Der AppHost wurde erfolgreich gestartet. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 22f5ddcb6dd..a39b710377e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -37,6 +37,11 @@ El proceso AppHost se cerró con código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost se ha iniciado correctamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index adaaed0befc..fb0a0a6e08a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -37,6 +37,11 @@ Processus AppHost arrêté avec le code {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost a démarré correctement. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index df0aadbf184..5296e1ed136 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -37,6 +37,11 @@ Processo AppHost terminato con codice {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost avviato correttamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 79e19c80b02..8e73ec517c9 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -37,6 +37,11 @@ AppHost プロセスが終了し、コード {0} を返しました。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost が正常に起動しました。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index f88d2ab250b..6d10f499767 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -37,6 +37,11 @@ AppHost 프로세스가 {0} 코드로 종료되었습니다. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost를 시작했습니다. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 3d299fe03aa..9f2531431c7 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -37,6 +37,11 @@ Proces hosta aplikacji zakończył się z kodem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Host aplikacji został pomyślnie uruchomiony. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 68c69dbc585..543eb04e807 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -37,6 +37,11 @@ O processo AppHost foi encerrado com o código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost iniciado com sucesso. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index f92d6c1056b..9ffd508797b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -37,6 +37,11 @@ Процесс AppHost завершился с кодом {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Запуск AppHost выполнен. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index f1802c2992a..6bd56480556 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -37,6 +37,11 @@ AppHost işlemi {0} koduyla sonlandırıldı. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost başarıyla başlatıldı. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index b23f5447f08..703a2736efa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -37,6 +37,11 @@ AppHost 进程已退出,代码为 {0}。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功启动 AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index f165b8200a3..20f829b7698 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -37,6 +37,11 @@ AppHost 程序以返回碼 {0} 結束。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功啟動 AppHost。 diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 4dfcb1fe116..35c05a11d96 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -55,25 +55,25 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat var directory = context.TargetDirectory; var language = context.Language; - // Step 1: Resolve SDK version from channel (if configured) or use default + // Step 1: Resolve SDK and package strategy var sdkVersion = await ResolveSdkVersionAsync(cancellationToken); - - // Load or create config with resolved SDK version var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, sdkVersion); // Include the code generation package for scaffolding and code gen var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken); - var packages = config.GetAllPackages().ToList(); + var packages = config.GetAllPackages(sdkVersion).ToList(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var prepareSdkVersion = config.GetEffectiveSdkVersion(sdkVersion); var prepareResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(config.SdkVersion!, packages, cancellationToken)); + () => appHostServerProject.PrepareAsync(prepareSdkVersion, packages, cancellationToken)); if (!prepareResult.Success) { if (prepareResult.Output is not null) @@ -134,6 +134,7 @@ await GenerateCodeViaRpcAsync( { config.Channel = prepareResult.ChannelName; } + config.Language = language.LanguageId; config.Save(directory.FullName); } diff --git a/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs new file mode 100644 index 00000000000..5f27b54d333 --- /dev/null +++ b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Cli.Utils; + +internal static class AspireRepositoryDetector +{ +#if DEBUG + private const string AspireSolutionFileName = "Aspire.slnx"; + + private static string? s_cachedRepoRoot; + private static bool s_cacheInitialized; +#endif + + public static string? DetectRepositoryRoot(string? startPath = null) + { +#if !DEBUG + // In release builds, only check the environment variable to avoid + // filesystem walking on every call in production scenarios. + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + return null; +#else + if (s_cacheInitialized) + { + return s_cachedRepoRoot; + } + + s_cachedRepoRoot = DetectRepositoryRootCore(startPath); + s_cacheInitialized = true; + return s_cachedRepoRoot; +#endif + } + +#if DEBUG + internal static void ResetCache() + { + s_cachedRepoRoot = null; + s_cacheInitialized = false; + } + + private static string? DetectRepositoryRootCore(string? startPath) + { + var repoRoot = FindRepositoryRoot(startPath); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + var processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + repoRoot = FindRepositoryRoot(Path.GetDirectoryName(processPath)); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + } + + return null; + } + + private static string? FindRepositoryRoot(string? startPath) + { + if (string.IsNullOrEmpty(startPath)) + { + return null; + } + + var currentDirectory = ResolveSearchDirectory(startPath); + while (!string.IsNullOrEmpty(currentDirectory)) + { + if (File.Exists(Path.Combine(currentDirectory, AspireSolutionFileName))) + { + return currentDirectory; + } + + currentDirectory = Directory.GetParent(currentDirectory)?.FullName; + } + + return null; + } + + private static string ResolveSearchDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + + if (Directory.Exists(fullPath)) + { + return fullPath; + } + + if (File.Exists(fullPath)) + { + return Path.GetDirectoryName(fullPath)!; + } + + var parentDirectory = Path.GetDirectoryName(fullPath); + return string.IsNullOrEmpty(parentDirectory) ? fullPath : parentDirectory; + } +#endif +} diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5a723f60c2a..78521083498 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Utils; /// rewriting the entire existing publishing pipeline. Integrates by mapping publish /// step/task events to Start/Progress/Success/Warning/Failure calls. /// -internal sealed class ConsoleActivityLogger +internal sealed partial class ConsoleActivityLogger { private readonly IAnsiConsole _console; private readonly bool _enableColor; @@ -304,21 +304,27 @@ public void WriteSummary() /// /// Formats a single key-value pair for the pipeline summary display. + /// Values may contain markdown links which are converted to clickable links when supported. /// private string FormatPipelineSummaryKvp(string key, string value) { if (_enableColor) { var escapedKey = key.EscapeMarkup(); - var escapedValue = value.EscapeMarkup(); - return $" [blue]{escapedKey}[/]: {escapedValue}"; + var convertedValue = MarkdownToSpectreConverter.ConvertToSpectre(value); + convertedValue = HighlightMessage(convertedValue); + return $" [blue]{escapedKey}[/]: {convertedValue}"; } else { - return $" {key}: {value}"; + var plainValue = MarkdownLinkToPlainTextRegex().Replace(value, "$1 ($2)"); + return $" {key}: {plainValue}"; } } + [GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")] + private static partial Regex MarkdownLinkToPlainTextRegex(); + /// /// Sets the final pipeline result lines to be displayed in the summary (e.g., PIPELINE FAILED ...). /// Optional usage so existing callers remain compatible. diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs index 0dad5597109..fe76d906051 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs @@ -43,6 +43,11 @@ public static partial class Anthropic /// public static readonly AIFoundryModel ClaudeOpus45 = new() { Name = "claude-opus-4-5", Version = "20251101", Format = "Anthropic" }; + /// + /// Claude Opus 4.6 is the latest version of Anthropic's most intelligent model, and the world's best model for coding, enterprise agents, and professional work. With a 1M token context window (beta) and 128K max output, Opus 4.6 is ideal for production code, + /// + public static readonly AIFoundryModel ClaudeOpus46 = new() { Name = "claude-opus-4-6", Version = "1", Format = "Anthropic" }; + /// /// Claude Sonnet 4.5 is Anthropic's most capable model for complex agents and an industry leader for coding and computer use. /// @@ -220,7 +225,7 @@ public static partial class Meta /// /// The Llama 3.1 instruction tuned text only models are optimized for multilingual dialogue use cases and outperform many of the available open source and closed chat models on common industry benchmarks. /// - public static readonly AIFoundryModel MetaLlama318BInstruct = new() { Name = "Meta-Llama-3.1-8B-Instruct", Version = "5", Format = "Meta" }; + public static readonly AIFoundryModel MetaLlama318BInstruct = new() { Name = "Meta-Llama-3.1-8B-Instruct", Version = "6", Format = "Meta" }; } /// diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 9699d2668d5..f1be92cc640 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -62,7 +62,7 @@ public static IResourceBuilder AddAzureServiceBus(this { var skuParameter = new ProvisioningParameter("sku", typeof(string)) { - Value = "Standard" + Value = hasPrivateEndpoint ? "Premium" : "Standard" }; infrastructure.Add(skuParameter); var resource = new AzureProvisioning.ServiceBusNamespace(infrastructure.AspireResource.GetBicepIdentifier()) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 19a62a9e3e5..7325e690d4b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -133,18 +133,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet /// The Azure provisioning context. private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) { - // Safely access the nested properties with null checks for reference types - // AzureLocation is a struct so it cannot be null - var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; - var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var resourceGroupName = provisioningContext.ResourceGroup.Name; + var subscriptionId = provisioningContext.Subscription.Id.Name; var location = provisioningContext.Location.Name; -#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental + var tenantId = provisioningContext.Tenant.TenantId; + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; + ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); -#pragma warning restore ASPIREPIPELINES001 } private Task PublishAsync(PipelineStepContext context) diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs index 99ff9181778..d5335b94750 100644 --- a/src/Shared/PackageUpdateHelpers.cs +++ b/src/Shared/PackageUpdateHelpers.cs @@ -144,7 +144,9 @@ public static List ParsePackageSearchResults(string stdout, string { var id = packageResult.GetProperty("id").GetString()!; - var version = packageResult.GetProperty("latestVersion").GetString()!; + var version = packageResult.TryGetProperty("latestVersion", out var latestVersionProp) + ? latestVersionProp.GetString()! + : packageResult.GetProperty("version").GetString()!; if (packageId == null || id == packageId) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs new file mode 100644 index 00000000000..14498306c9d --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for staging channel configuration and self-update channel switching. +/// Verifies that staging settings (overrideStagingQuality, stagingPinToCliVersion) are +/// correctly persisted and that aspire update --self saves the channel to global settings. +/// +public sealed class StagingChannelTests(ITestOutputHelper output) +{ + [Fact] + public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Configure staging channel settings via aspire config set + // Enable the staging channel feature flag + sequenceBuilder + .Type("aspire config set features.stagingChannelEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set quality to Prerelease (triggers shared feed mode) + sequenceBuilder + .Type("aspire config set overrideStagingQuality Prerelease -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Enable pinned version mode + sequenceBuilder + .Type("aspire config set stagingPinToCliVersion true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set channel to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Verify the settings were persisted in the global settings file + var settingsFilePattern = new CellPatternSearcher() + .Find("stagingPinToCliVersion"); + + sequenceBuilder + .ClearScreen(counter) + .Type("cat ~/.aspire/globalsettings.json") + .Enter() + .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 3: Verify aspire config get returns the correct values + var stagingChannelPattern = new CellPatternSearcher() + .Find("staging"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 4: Verify the CLI version is available (basic smoke test that the CLI works with these settings) + sequenceBuilder + .ClearScreen(counter) + .Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Switch channel to stable via config set (simulating what update --self does) + sequenceBuilder + .Type("aspire config set channel stable -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Verify channel was changed to stable + var stableChannelPattern = new CellPatternSearcher() + .Find("stable"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 7: Switch back to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Verify channel is staging again and staging settings are still present + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Verify the staging-specific settings survived the channel switch + var prereleasePattern = new CellPatternSearcher() + .Find("Prerelease"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get overrideStagingQuality") + .Enter() + .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Clean up: remove staging settings to avoid polluting other tests + sequenceBuilder + .Type("aspire config delete features.stagingChannelEnabled -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete overrideStagingQuality -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete stagingPinToCliVersion -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete channel -g") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2a84fbbe614..0cdf41939b4 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -87,6 +87,38 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + [Fact] + public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(ExitCodeConstants.FailedToBuildArtifacts); + + Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message); + } + + [Fact] + public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(123); + + Assert.Contains("123", message, StringComparison.Ordinal); + } + + [Fact] + public void GenerateChildLogFilePath_UsesDetachChildNamingWithoutProcessId() + { + var logsDirectory = Path.Combine(Path.GetTempPath(), "aspire-cli-tests"); + var now = new DateTimeOffset(2026, 02, 12, 18, 00, 00, TimeSpan.Zero); + var timeProvider = new FixedTimeProvider(now); + + var path = RunCommand.GenerateChildLogFilePath(logsDirectory, timeProvider); + var fileName = Path.GetFileName(path); + + Assert.StartsWith(logsDirectory, path, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("cli_20260212T180000000_detach-child_", fileName, StringComparison.Ordinal); + Assert.EndsWith(".log", fileName, StringComparison.Ordinal); + Assert.DoesNotContain($"_{Environment.ProcessId}", fileName, StringComparison.Ordinal); + } + private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator { public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken) @@ -166,6 +198,11 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf } } + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + private async IAsyncEnumerable ReturnLogEntriesUntilCancelledAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var logEntryIndex = 0; diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 74b71823ea7..51ce56b6907 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -637,7 +637,7 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -652,8 +652,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "13.2" + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -664,13 +664,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.NotNull(stagingChannel.VersionPrefix); - Assert.Equal(13, stagingChannel.VersionPrefix!.Major); - Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + Assert.NotNull(stagingChannel.PinnedVersion); + // Should not contain build metadata (+hash) + Assert.DoesNotContain("+", stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNoPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -685,7 +685,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion }) .Build(); @@ -696,13 +697,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + Assert.Null(stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed_ChannelHasNoPinnedVersion() { - // Arrange + // Arrange - pin is set but explicit feed override means not using shared feed using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -716,7 +717,7 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV .AddInMemoryCollection(new Dictionary { ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "not-a-version" + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -727,6 +728,188 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + // With explicit feed override, useSharedFeed is false, so pinning is not activated + Assert.Null(stagingChannel.PinnedVersion); + } + + /// + /// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result + /// with the pinned version, bypassing actual NuGet search. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() + { + // Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26200.5", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26110.3", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should return exactly one synthetic package with the CLI's pinned version + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Single(packageList); + Assert.Equal("Aspire.ProjectTemplates", packageList[0].Id); + Assert.Equal(stagingChannel.PinnedVersion, packageList[0].Version); + // Pinned version should not contain build metadata + Assert.DoesNotContain("+", packageList[0].Version!); + } + + /// + /// Verifies that when pinned to CLI version, GetIntegrationPackagesAsync discovers packages + /// from the feed but overrides their version to the pinned version. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVersions() + { + // Arrange - integration packages with various versions + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.Hosting.Redis", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.Hosting.PostgreSQL", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var integrationPackages = await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should discover both packages but with pinned version + var packageList = integrationPackages.ToList(); + outputHelper.WriteLine($"Integration packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Equal(2, packageList.Count); + Assert.All(packageList, p => Assert.Equal(stagingChannel.PinnedVersion, p.Version)); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis"); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.PostgreSQL"); + } + + /// + /// Verifies that without pinning, all prerelease packages from the feed are returned as-is. + /// + [Fact] + public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackages() + { + // Arrange + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion — should return all prerelease + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + // Should return only the prerelease ones (quality filter), but both 13.3 and 13.2 + Assert.Equal(2, packageList.Count); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.3")); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.2")); + } + + private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache + { + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + // Simulate what the real cache does: filter by prerelease flag + var filtered = prerelease + ? packages.Where(p => Semver.SemVersion.Parse(p.Version).IsPrerelease) + : packages.Where(p => !Semver.SemVersion.Parse(p.Version).IsPrerelease); + return Task.FromResult>(filtered.ToList()); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); } } diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs new file mode 100644 index 00000000000..bcaa4d6b7fa --- /dev/null +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests; + +public class ProgramTests +{ + [Fact] + public void ParseLogFileOption_ReturnsNull_WhenArgsAreNull() + { + var result = Program.ParseLogFileOption(null); + + Assert.Null(result); + } + + [Fact] + public void ParseLogFileOption_ReturnsValue_WhenOptionAppearsBeforeDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--log-file", "cli.log", "--", "--log-file", "app.log"]); + + Assert.Equal("cli.log", result); + } + + [Fact] + public void ParseLogFileOption_IgnoresValue_WhenOptionAppearsAfterDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--", "--log-file", "app.log"]); + + Assert.Null(result); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 36db392a5ba..876be8d1451 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -165,6 +165,64 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); } + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithWhitespaceSdkVersion_Throws() + { + var config = new AspireJsonConfiguration + { + SdkVersion = " ", + Language = "typescript" + }; + + var exception = Assert.Throws(() => config.GetAllPackages().ToList()); + + Assert.Contains("non-empty", exception.Message); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithDefaultSdkVersion_UsesFallbackVersion() + { + // Arrange + var config = new AspireJsonConfiguration + { + Language = "typescript", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = string.Empty + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithConfiguredSdkVersion_ReturnsConfiguredVersions() + { + // Arrange + var config = new AspireJsonConfiguration + { + SdkVersion = "13.1.0", + Language = "typescript", + Channel = "daily", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "13.1.0" + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + [Fact] public void AspireJsonConfiguration_Save_PreservesExtensionData() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs index bc0b7d20918..3c99f10bc7c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs @@ -117,6 +117,11 @@ public TestAppHostProject(TestAppHostProjectFactory factory) public string DisplayName => "C# (.NET)"; public string? AppHostFileName => "AppHost.csproj"; + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + public Task GetDetectionPatternsAsync(CancellationToken cancellationToken) => Task.FromResult(s_detectionPatterns); diff --git a/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs new file mode 100644 index 00000000000..ed8e40806cc --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if DEBUG + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class AspireRepositoryDetectorTests : IDisposable +{ + private const string RepoRootEnvironmentVariableName = "ASPIRE_REPO_ROOT"; + private readonly List _directoriesToDelete = []; + private readonly string? _originalRepoRoot = Environment.GetEnvironmentVariable(RepoRootEnvironmentVariableName); + + public AspireRepositoryDetectorTests() + { + AspireRepositoryDetector.ResetCache(); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, _originalRepoRoot); + AspireRepositoryDetector.ResetCache(); + + foreach (var directory in _directoriesToDelete) + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void DetectRepositoryRoot_ReturnsDirectoryContainingAspireSolution() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "src", "Project")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_UsesEnvironmentVariable_WhenNoSolutionFound() + { + var repoRoot = CreateTempDirectory(); + var workingDirectory = CreateTempDirectory(); + + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, repoRoot); + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(workingDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_PrefersSolutionSearchOverEnvironmentVariable() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var envRoot = CreateTempDirectory(); + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, envRoot); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "playground", "polyglot")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + private string CreateTempDirectory() + { + var directory = Directory.CreateTempSubdirectory("aspire-repo-detector-tests-").FullName; + _directoriesToDelete.Add(directory); + return directory; + } +} + +#endif diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index ec634093d30..77deca3237b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -28,7 +28,7 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( { var cache = new TestNuGetPackageCache(); cache.SetMockCliPackages([ - // Should be ignored because its lower that current prerelease version. + // Should be ignored because it's lower than current prerelease version. new NuGetPackage { Id = "Aspire.Cli", Version = "9.3.1", Source = "nuget.org" }, // Should be selected because it is higher than 9.4.0-dev (dev and preview sort using alphabetical sort). diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs new file mode 100644 index 00000000000..20015aaf440 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Utils; + +public class ConsoleActivityLoggerTests +{ + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("📦 Resource Group", "VNetTest5 ([link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview))"), + new("🔑 Subscription", "sub-id"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // Verify the markdown link was converted to a Spectre link + Assert.Contains("VNetTest5", result); + Assert.Contains("link", result); + Assert.Contains("portal.azure.com", result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainTextWithUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); + + var portalUrl = "https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 ([link]({portalUrl}))"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is disabled, markdown links should be converted to plain text: text (url) + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + } + + [Fact] + public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + Assert.Contains("Azure", result); + Assert.Contains("eastus", result); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep index 83542945cf7..1b773d6afc7 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -1,7 +1,7 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param sku string = 'Standard' +param sku string = 'Premium' resource servicebus 'Microsoft.ServiceBus/namespaces@2024-01-01' = { name: take('servicebus-${uniqueString(resourceGroup().id)}', 50) diff --git a/tests/Shared/TemporaryRepo.cs b/tests/Shared/TemporaryRepo.cs index ffe7c0d9352..e61ff2d4c48 100644 --- a/tests/Shared/TemporaryRepo.cs +++ b/tests/Shared/TemporaryRepo.cs @@ -62,6 +62,12 @@ internal static TemporaryWorkspace Create(ITestOutputHelper outputHelper) var repoDirectory = Directory.CreateDirectory(path); outputHelper.WriteLine($"Temporary workspace created at: {repoDirectory.FullName}"); + // Create an empty settings file so directory-walking searches + // (ConfigurationHelper, ConfigurationService) stop here instead + // of finding the user's actual ~/.aspire/settings.json. + var aspireDir = Directory.CreateDirectory(Path.Combine(path, ".aspire")); + File.WriteAllText(Path.Combine(aspireDir.FullName, "settings.json"), "{}"); + return new TemporaryWorkspace(outputHelper, repoDirectory); } }