From 94b134981dcf94c6f45f38f5dd01325e55b26336 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 7 Apr 2026 16:01:59 -0400 Subject: [PATCH 1/6] Add .aspire-update.json self-update opt-out mechanism (#15932) Introduce IInstallationDetector service that detects how the CLI was installed and whether self-update is available. When a .aspire-update.json file is present next to the CLI binary with selfUpdateDisabled: true, all three self-update paths are guarded: - aspire update --self: shows disabled message and update instructions - Post-project-update prompt: shows instructions instead of prompting - No-project-found fallback: shows instructions instead of prompting The CliUpdateNotifier also suppresses background update notifications when self-update is disabled (e.g., WinGet/Homebrew installs). Key design decisions: - Symlink resolution before config lookup (critical for Homebrew) - Fail-closed on malformed/unreadable JSON (safer for package managers) - Cached result (computed once per process lifetime) - DotNet tool detection takes priority over config file - Fixes pre-existing bug where post-project-update prompt didn't check IsRunningAsDotNetTool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/UpdateCommand.cs | 89 ++-- src/Aspire.Cli/JsonSourceGenerationContext.cs | 2 + src/Aspire.Cli/Program.cs | 1 + .../UpdateCommandStrings.Designer.cs | 1 + .../Resources/UpdateCommandStrings.resx | 3 + .../Resources/xlf/UpdateCommandStrings.cs.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.de.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.es.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.fr.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.it.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ja.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ko.xlf | 7 +- .../Resources/xlf/UpdateCommandStrings.pl.xlf | 5 + .../xlf/UpdateCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ru.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.tr.xlf | 5 + .../xlf/UpdateCommandStrings.zh-Hans.xlf | 5 + .../xlf/UpdateCommandStrings.zh-Hant.xlf | 5 + src/Aspire.Cli/Utils/CliUpdateNotifier.cs | 45 +- src/Aspire.Cli/Utils/InstallationDetector.cs | 184 ++++++++ .../TestServices/TestInstallationDetector.cs | 13 + tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 6 +- .../CliUpdateNotificationServiceTests.cs | 14 +- .../Utils/InstallationDetectorTests.cs | 413 ++++++++++++++++++ 24 files changed, 772 insertions(+), 66 deletions(-) create mode 100644 src/Aspire.Cli/Utils/InstallationDetector.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestInstallationDetector.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/InstallationDetectorTests.cs diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 665bd0f9208..54fc25b1046 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -32,6 +32,7 @@ internal sealed class UpdateCommand : BaseCommand private readonly IFeatures _features; private readonly IConfigurationService _configurationService; private readonly IConfiguration _configuration; + private readonly IInstallationDetector _installationDetector; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", UpdateCommandStrings.ProjectArgumentDescription); private static readonly Option s_selfOption = new("--self") @@ -53,7 +54,8 @@ public UpdateCommand( CliExecutionContext executionContext, IConfigurationService configurationService, AspireCliTelemetry telemetry, - IConfiguration configuration) + IConfiguration configuration, + IInstallationDetector installationDetector) : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _projectLocator = projectLocator; @@ -65,6 +67,7 @@ public UpdateCommand( _features = features; _configurationService = configurationService; _configuration = configuration; + _installationDetector = installationDetector; Options.Add(s_appHostOption); Options.Add(s_selfOption); @@ -93,20 +96,6 @@ public UpdateCommand( protected override bool UpdateNotificationsEnabled => false; - private static bool IsRunningAsDotNetTool() - { - // When running as a dotnet tool, the process path points to "dotnet" or "dotnet.exe" - // When running as a native binary, it points to "aspire" or "aspire.exe" - var processPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(processPath)) - { - return false; - } - - var fileName = Path.GetFileNameWithoutExtension(processPath); - return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); - } - protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var isSelfUpdate = parseResult.GetValue(s_selfOption); @@ -114,14 +103,27 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // If --self is specified, handle CLI self-update if (isSelfUpdate) { + var installInfo = _installationDetector.GetInstallationInfo(); + // When running as a dotnet tool, print the update command instead of executing - if (IsRunningAsDotNetTool()) + if (installInfo.IsDotNetTool) { InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage); InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli"); return 0; } + // When self-update is disabled (e.g., installed via WinGet/Homebrew), show instructions + if (installInfo.SelfUpdateDisabled) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage); + if (!string.IsNullOrEmpty(installInfo.UpdateInstructions)) + { + InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}"); + } + return 0; + } + if (_cliDownloader is null) { InteractionService.DisplayError("CLI self-update is not available in this environment."); @@ -208,15 +210,35 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _updateNotifier.IsUpdateAvailable() && !string.IsNullOrEmpty(channel.CliDownloadBaseUrl)) { - var shouldUpdateCli = await InteractionService.ConfirmAsync( - UpdateCommandStrings.UpdateCliAfterProjectUpdatePrompt, - defaultValue: true, - cancellationToken); - - if (shouldUpdateCli) + var installInfo = _installationDetector.GetInstallationInfo(); + + if (installInfo.IsDotNetTool) + { + // Show dotnet tool update message instead of prompting + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage); + InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli"); + } + else if (installInfo.SelfUpdateDisabled) + { + // Show package manager instructions instead of prompting + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage); + if (!string.IsNullOrEmpty(installInfo.UpdateInstructions)) + { + InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}"); + } + } + else { - // Use the same channel that was selected for the project update - return await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name); + var shouldUpdateCli = await InteractionService.ConfirmAsync( + UpdateCommandStrings.UpdateCliAfterProjectUpdatePrompt, + defaultValue: true, + cancellationToken); + + if (shouldUpdateCli) + { + // Use the same channel that was selected for the project update + return await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name); + } } } } @@ -239,8 +261,23 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Check if this is a "no project found" error and prompt for self-update if (string.Equals(ex.Message, ErrorStrings.NoProjectFileFound, StringComparisons.CliInputOrOutput)) { - // Only prompt for self-update if not running as dotnet tool and downloader is available - if (_cliDownloader is not null) + var installInfo = _installationDetector.GetInstallationInfo(); + + // Only prompt for self-update if not running as dotnet tool, not disabled, and downloader is available + if (installInfo.IsDotNetTool) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage); + InteractionService.DisplayPlainText(" dotnet tool update -g Aspire.Cli"); + } + else if (installInfo.SelfUpdateDisabled) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.SelfUpdateDisabledMessage); + if (!string.IsNullOrEmpty(installInfo.UpdateInstructions)) + { + InteractionService.DisplayPlainText($" {installInfo.UpdateInstructions}"); + } + } + else if (_cliDownloader is not null) { var shouldUpdateCli = await InteractionService.ConfirmAsync( UpdateCommandStrings.NoAppHostFoundUpdateCliPrompt, diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index b0fadbcf717..47b17ce5422 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Mcp.Docs; using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Utils; using Aspire.Cli.Utils.EnvironmentChecker; namespace Aspire.Cli; @@ -40,6 +41,7 @@ namespace Aspire.Cli; [JsonSerializable(typeof(DocsListItem[]))] [JsonSerializable(typeof(SearchResult[]))] [JsonSerializable(typeof(DocsContent))] +[JsonSerializable(typeof(AspireUpdateConfig))] internal partial class JsonSourceGenerationContext : JsonSerializerContext { private static JsonSourceGenerationContext? s_relaxedEscaping; diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 2bcdfbf53e6..1cd5a368726 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -365,6 +365,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index fd4c0c24794..60e8d7ecd2e 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -116,5 +116,6 @@ internal static string ProjectArgumentDescription { internal static string RegeneratingSdkCode => ResourceManager.GetString("RegeneratingSdkCode", resourceCulture); internal static string RegeneratedSdkCode => ResourceManager.GetString("RegeneratedSdkCode", resourceCulture); internal static string SelfOptionDescription => ResourceManager.GetString("SelfOptionDescription", resourceCulture); + internal static string SelfUpdateDisabledMessage => ResourceManager.GetString("SelfUpdateDisabledMessage", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 28c75afa616..667a192fd1e 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -165,4 +165,7 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index 1f5f8620746..ffc4a646e9e 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Neočekávaná cesta kódu. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index 306ceba6c2e..23ece6283e8 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Unerwarteter Codepfad diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 01572c31716..90ecd2ed51c 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Ruta de acceso al código inesperada. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index d49b7cd1224..fbbbb50fe8f 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Chemin de code inattendu. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index e2977671f34..6017ef0c7b1 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Percorso del codice imprevisto. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index 9a38b494f8a..25511287d39 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. 予期しないコード パスです。 diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index 559dc98324d..d583e70d971 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. 예기치 않은 코드 경로입니다. @@ -259,4 +264,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index dc5d74890bc..ad983a4f2ca 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Nieoczekiwana ścieżka w kodzie. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index efa4753eba1..44121218f53 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Caminho de código inesperado. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index da4e1a42bc1..f4fc80dfdbd 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Непредвиденный путь к коду. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index 2c47d6412a5..023673b9fc1 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. Beklenmeyen kod yolu. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index d040eea59ab..cd3b87b3035 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. 意外的代码路径。 diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index 3802cec1fdd..db2a21178a8 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -232,6 +232,11 @@ Update the Aspire CLI itself to the latest version + + Self-update is disabled for this installation. + Self-update is disabled for this installation. + + Unexpected code path. 未預期的程式碼路徑。 diff --git a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs index 68fbdbcc4a7..267dbf34332 100644 --- a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs +++ b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs @@ -19,7 +19,8 @@ internal interface ICliUpdateNotifier internal class CliUpdateNotifier( ILogger logger, INuGetPackageCache nuGetPackageCache, - IInteractionService interactionService) : ICliUpdateNotifier + IInteractionService interactionService, + IInstallationDetector installationDetector) : ICliUpdateNotifier { private IEnumerable? _availablePackages; @@ -50,7 +51,16 @@ public void NotifyIfUpdateAvailable() if (newerVersion is not null) { - var updateCommand = IsRunningAsDotNetTool() + var installInfo = installationDetector.GetInstallationInfo(); + + if (installInfo.SelfUpdateDisabled) + { + // Suppress the update notification entirely when self-update is disabled + // (e.g., installed via WinGet/Homebrew) + return; + } + + var updateCommand = installInfo.IsDotNetTool ? "dotnet tool update -g Aspire.Cli" : "aspire update"; @@ -75,37 +85,6 @@ public bool IsUpdateAvailable() return newerVersion is not null; } - /// - /// Determines whether the Aspire CLI is running as a .NET tool or as a native binary. - /// - /// - /// true if running as a .NET tool (process name is "dotnet" or "dotnet.exe"); - /// false if running as a native binary (process name is "aspire" or "aspire.exe") or if the process path cannot be determined. - /// - /// - /// This detection is used to determine which update command to display to users: - /// - /// .NET tool installation: "dotnet tool update -g Aspire.Cli" - /// Native binary installation: "aspire update --self" - /// - /// The detection works by examining , which returns the full path to the current executable. - /// When running as a .NET tool, this path points to the dotnet host executable. When running as a native binary, - /// it points to the aspire executable itself. - /// - private static bool IsRunningAsDotNetTool() - { - // When running as a dotnet tool, the process path points to "dotnet" or "dotnet.exe" - // When running as a native binary, it points to "aspire" or "aspire.exe" - var processPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(processPath)) - { - return false; - } - - var fileName = Path.GetFileNameWithoutExtension(processPath); - return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); - } - protected virtual SemVersion? GetCurrentVersion() { return PackageUpdateHelpers.GetCurrentPackageVersion(); diff --git a/src/Aspire.Cli/Utils/InstallationDetector.cs b/src/Aspire.Cli/Utils/InstallationDetector.cs new file mode 100644 index 00000000000..fd735a12a45 --- /dev/null +++ b/src/Aspire.Cli/Utils/InstallationDetector.cs @@ -0,0 +1,184 @@ +// 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.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +/// +/// Information about the CLI installation method and self-update availability. +/// +internal sealed record InstallationInfo(bool IsDotNetTool, bool SelfUpdateDisabled, string? UpdateInstructions); + +/// +/// Detects how the CLI was installed and whether self-update is available. +/// +internal interface IInstallationDetector +{ + /// + /// Gets information about the current CLI installation. + /// + InstallationInfo GetInstallationInfo(); +} + +/// +/// Model for the .aspire-update.json file that can disable self-update. +/// +internal sealed class AspireUpdateConfig +{ + [JsonPropertyName("selfUpdateDisabled")] + public bool SelfUpdateDisabled { get; set; } + + [JsonPropertyName("updateInstructions")] + public string? UpdateInstructions { get; set; } +} + +/// +/// Detects CLI installation method by checking for .aspire-update.json and dotnet tool indicators. +/// +internal sealed class InstallationDetector : IInstallationDetector +{ + private readonly ILogger _logger; + private readonly string? _processPath; + private InstallationInfo? _cachedInfo; + + internal const string UpdateConfigFileName = ".aspire-update.json"; + + public InstallationDetector(ILogger logger) + : this(logger, Environment.ProcessPath) + { + } + + /// + /// Constructor that accepts a process path for testability. + /// + internal InstallationDetector(ILogger logger, string? processPath) + { + _logger = logger; + _processPath = processPath; + } + + public InstallationInfo GetInstallationInfo() + { + if (_cachedInfo is not null) + { + return _cachedInfo; + } + + _cachedInfo = DetectInstallation(); + return _cachedInfo; + } + + private InstallationInfo DetectInstallation() + { + // Check if running as a dotnet tool first + if (IsDotNetToolProcess(_processPath)) + { + _logger.LogDebug("CLI is running as a .NET tool."); + return new InstallationInfo(IsDotNetTool: true, SelfUpdateDisabled: false, UpdateInstructions: null); + } + + // Check for .aspire-update.json next to the resolved process path + var config = TryLoadUpdateConfig(_processPath); + if (config is not null) + { + if (config.SelfUpdateDisabled) + { + _logger.LogDebug("Self-update is disabled via {FileName}.", UpdateConfigFileName); + return new InstallationInfo(IsDotNetTool: false, SelfUpdateDisabled: true, UpdateInstructions: config.UpdateInstructions); + } + + _logger.LogDebug("{FileName} found but selfUpdateDisabled is false.", UpdateConfigFileName); + } + + // Default: script install or direct binary, self-update is available + return new InstallationInfo(IsDotNetTool: false, SelfUpdateDisabled: false, UpdateInstructions: null); + } + + private static bool IsDotNetToolProcess(string? processPath) + { + if (string.IsNullOrEmpty(processPath)) + { + return false; + } + + var fileName = Path.GetFileNameWithoutExtension(processPath); + return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); + } + + private AspireUpdateConfig? TryLoadUpdateConfig(string? processPath) + { + if (string.IsNullOrEmpty(processPath)) + { + return null; + } + + try + { + // Resolve symlinks (critical for Homebrew on macOS where the binary is symlinked). + // File.ResolveLinkTarget returns null for non-symlinks (not an error). + // On Linux, Environment.ProcessPath reads /proc/self/exe (already resolved). + var resolvedPath = processPath; + try + { + var linkTarget = File.ResolveLinkTarget(processPath, returnFinalTarget: true); + if (linkTarget is not null) + { + resolvedPath = linkTarget.FullName; + _logger.LogDebug("Resolved symlink {ProcessPath} -> {ResolvedPath}", processPath, resolvedPath); + } + } + catch (IOException ex) + { + _logger.LogDebug(ex, "Failed to resolve symlink for {ProcessPath}, using original path.", processPath); + } + + var directory = Path.GetDirectoryName(resolvedPath); + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + var configPath = Path.Combine(directory, UpdateConfigFileName); + if (!File.Exists(configPath)) + { + _logger.LogDebug("{FileName} not found at {ConfigPath}.", UpdateConfigFileName, configPath); + return null; + } + + _logger.LogDebug("Found {FileName} at {ConfigPath}.", UpdateConfigFileName, configPath); + + var json = File.ReadAllText(configPath); + var config = JsonSerializer.Deserialize(json, JsonSourceGenerationContext.Default.AspireUpdateConfig); + + if (config is null) + { + // Null deserialization result (e.g., "null" literal in JSON) — fail closed + _logger.LogWarning("Failed to parse {FileName}: deserialized to null. Treating as self-update disabled.", UpdateConfigFileName); + return new AspireUpdateConfig { SelfUpdateDisabled = true }; + } + + return config; + } + catch (JsonException ex) + { + // Malformed JSON — fail closed (safer for package managers) + _logger.LogWarning(ex, "Failed to parse {FileName}. Treating as self-update disabled.", UpdateConfigFileName); + return new AspireUpdateConfig { SelfUpdateDisabled = true }; + } + catch (IOException ex) + { + // File read error — fail closed + _logger.LogWarning(ex, "Failed to read {FileName}. Treating as self-update disabled.", UpdateConfigFileName); + return new AspireUpdateConfig { SelfUpdateDisabled = true }; + } + catch (UnauthorizedAccessException ex) + { + // Permission error — fail closed + _logger.LogWarning(ex, "Failed to read {FileName}. Treating as self-update disabled.", UpdateConfigFileName); + return new AspireUpdateConfig { SelfUpdateDisabled = true }; + } + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInstallationDetector.cs b/tests/Aspire.Cli.Tests/TestServices/TestInstallationDetector.cs new file mode 100644 index 00000000000..23cb4e1beb8 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestInstallationDetector.cs @@ -0,0 +1,13 @@ +// 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.Utils; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestInstallationDetector : IInstallationDetector +{ + public InstallationInfo InstallationInfo { get; set; } = new(IsDotNetTool: false, SelfUpdateDisabled: false, UpdateInstructions: null); + + public InstallationInfo GetInstallationInfo() => InstallationInfo; +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 6d4d2f1ea84..261687d120f 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -112,6 +112,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.ConfigurationServiceFactory); services.AddSingleton(options.FeatureFlagsFactory); services.AddSingleton(options.CliUpdateNotifierFactory); + services.AddSingleton(options.InstallationDetectorFactory); services.AddSingleton(options.DotNetSdkInstallerFactory); services.AddSingleton(options.PackagingServiceFactory); services.AddSingleton(options.CliExecutionContextFactory); @@ -315,9 +316,12 @@ private static IAnsiConsole CreateAnsiConsole(TextWriter textWriter, bool ansi = var logger = NullLoggerFactory.Instance.CreateLogger(); var nuGetPackageCache = serviceProvider.GetRequiredService(); var interactionService = serviceProvider.GetRequiredService(); - return new CliUpdateNotifier(logger, nuGetPackageCache, interactionService); + var installationDetector = serviceProvider.GetRequiredService(); + return new CliUpdateNotifier(logger, nuGetPackageCache, interactionService, installationDetector); }; + public Func InstallationDetectorFactory { get; set; } = _ => new TestInstallationDetector(); + public Func AddCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) => { var interactionService = serviceProvider.GetRequiredService(); diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 081dc306dbc..d587bb2abe7 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -57,9 +57,10 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( var logger = sp.GetRequiredService>(); var nuGetPackageCache = sp.GetRequiredService(); var interactionService = sp.GetRequiredService(); + var installationDetector = sp.GetRequiredService(); // Use a custom notifier that overrides the current version - return new CliUpdateNotifierWithPackageVersionOverride("9.4.0-dev", logger, nuGetPackageCache, interactionService); + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0-dev", logger, nuGetPackageCache, interactionService, installationDetector); }; }); @@ -112,9 +113,10 @@ public async Task PrereleaseWillRecommendUpgradeToStableInCurrentVersionFamily() var logger = sp.GetRequiredService>(); var nuGetPackageCache = sp.GetRequiredService(); var interactionService = sp.GetRequiredService(); + var installationDetector = sp.GetRequiredService(); // Use a custom notifier that overrides the current version - return new CliUpdateNotifierWithPackageVersionOverride("9.4.0-dev", logger, nuGetPackageCache, interactionService); + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0-dev", logger, nuGetPackageCache, interactionService, installationDetector); }; }); @@ -167,9 +169,10 @@ public async Task StableWillOnlyRecommendGoingToNewerStable() var logger = sp.GetRequiredService>(); var nuGetPackageCache = sp.GetRequiredService(); var interactionService = sp.GetRequiredService(); + var installationDetector = sp.GetRequiredService(); // Use a custom notifier that overrides the current version - return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, interactionService); + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, interactionService, installationDetector); }; }); @@ -218,9 +221,10 @@ public async Task StableWillNotRecommendUpdatingToPreview() var logger = sp.GetRequiredService>(); var nuGetPackageCache = sp.GetRequiredService(); var interactionService = sp.GetRequiredService(); + var installationDetector = sp.GetRequiredService(); // Use a custom notifier that overrides the current version - return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, interactionService); + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, interactionService, installationDetector); }; }); @@ -276,7 +280,7 @@ public async Task NotifyIfUpdateAvailableAsync_WithEmptyPackages_DoesNotThrow() } } -internal sealed class CliUpdateNotifierWithPackageVersionOverride(string currentVersion, ILogger logger, INuGetPackageCache nuGetPackageCache, IInteractionService interactionService) : CliUpdateNotifier(logger, nuGetPackageCache, interactionService) +internal sealed class CliUpdateNotifierWithPackageVersionOverride(string currentVersion, ILogger logger, INuGetPackageCache nuGetPackageCache, IInteractionService interactionService, IInstallationDetector installationDetector) : CliUpdateNotifier(logger, nuGetPackageCache, interactionService, installationDetector) { protected override SemVersion? GetCurrentVersion() { diff --git a/tests/Aspire.Cli.Tests/Utils/InstallationDetectorTests.cs b/tests/Aspire.Cli.Tests/Utils/InstallationDetectorTests.cs new file mode 100644 index 00000000000..b85d2ad9835 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/InstallationDetectorTests.cs @@ -0,0 +1,413 @@ +// 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.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Utils; + +public class InstallationDetectorTests +{ + private readonly ILogger _logger = NullLoggerFactory.Instance.CreateLogger(); + + [Fact] + public void GetInstallationInfo_NoProcessPath_ReturnsDefault() + { + var detector = new InstallationDetector(_logger, processPath: null); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + + [Fact] + public void GetInstallationInfo_EmptyProcessPath_ReturnsDefault() + { + var detector = new InstallationDetector(_logger, processPath: ""); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + + [Fact] + public void GetInstallationInfo_DotNetProcessPath_ReturnsDotNetTool() + { + var detector = new InstallationDetector(_logger, processPath: "/usr/local/share/dotnet/dotnet"); + + var info = detector.GetInstallationInfo(); + + Assert.True(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + + [Fact] + public void GetInstallationInfo_DotNetExeProcessPath_ReturnsDotNetTool() + { + // Use platform-appropriate path separator since Path.GetFileNameWithoutExtension + // is platform-specific + var processPath = Path.Combine("some", "path", "dotnet.exe"); + var detector = new InstallationDetector(_logger, processPath: processPath); + + var info = detector.GetInstallationInfo(); + + Assert.True(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + + [Fact] + public void GetInstallationInfo_NoConfigFile_ReturnsDefault() + { + // Use a temp directory with no .aspire-update.json + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); // Create a fake binary + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_ConfigFileWithDisabledTrue_ReturnsSelfUpdateDisabled() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": true, + "updateInstructions": "Please use 'winget upgrade Microsoft.Aspire.Cli' to update." + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.True(info.SelfUpdateDisabled); + Assert.Equal("Please use 'winget upgrade Microsoft.Aspire.Cli' to update.", info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_ConfigFileWithDisabledFalse_ReturnsDefault() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": false + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_MalformedJson_FailsClosed() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, "not valid json {{{"); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.True(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_EmptyJsonFile_FailsClosed() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, ""); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.True(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_MissingSelfUpdateDisabledField_ReturnsDefault() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "updateInstructions": "some instructions" + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + // selfUpdateDisabled defaults to false, so self-update is not disabled + Assert.False(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_DisabledWithNoInstructions_ReturnsNullInstructions() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": true + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.True(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_ResultIsCached() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": true, + "updateInstructions": "use winget" + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info1 = detector.GetInstallationInfo(); + Assert.True(info1.SelfUpdateDisabled); + + // Delete the file - cached result should still be returned + File.Delete(configPath); + + var info2 = detector.GetInstallationInfo(); + Assert.True(info2.SelfUpdateDisabled); + Assert.Same(info1, info2); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_JsonWithComments_ParsesSuccessfully() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + // This file was generated by the WinGet package + "selfUpdateDisabled": true, + "updateInstructions": "Use winget upgrade" + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.True(info.SelfUpdateDisabled); + Assert.Equal("Use winget upgrade", info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_JsonWithTrailingComma_ParsesSuccessfully() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": true, + "updateInstructions": "Use winget upgrade", + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.True(info.SelfUpdateDisabled); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_DotNetToolTakesPriorityOverConfigFile() + { + // Even if .aspire-update.json exists next to dotnet, it should still be detected as a dotnet tool + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "dotnet"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, """ + { + "selfUpdateDisabled": true + } + """); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.True(info.IsDotNetTool); + Assert.False(info.SelfUpdateDisabled); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetInstallationInfo_NullJsonLiteral_FailsClosed() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-detector-test"); + try + { + var processPath = Path.Combine(tempDir.FullName, "aspire"); + File.WriteAllText(processPath, ""); + + var configPath = Path.Combine(tempDir.FullName, InstallationDetector.UpdateConfigFileName); + File.WriteAllText(configPath, "null"); + + var detector = new InstallationDetector(_logger, processPath); + + var info = detector.GetInstallationInfo(); + + Assert.False(info.IsDotNetTool); + Assert.True(info.SelfUpdateDisabled); + Assert.Null(info.UpdateInstructions); + } + finally + { + tempDir.Delete(recursive: true); + } + } +} From 80a22625ab054c798f5a5cc703306cbee98e8133 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 7 Apr 2026 18:20:23 -0400 Subject: [PATCH 2/6] Add tests and plumb .aspire-update.json into distribution archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - UpdateCommand --self when SelfUpdateDisabled shows disabled message - UpdateCommand post-project-update skips confirm prompt when disabled - UpdateCommand no-project-found shows instructions without prompt - CliUpdateNotifier suppresses notification when SelfUpdateDisabled=true Distribution plumbing: - Common.projitems: Write .aspire-update.json per-RID (win→winget, osx→brew) - Bundle.proj: Forward UpdateInstructions property to CreateLayout - CreateLayout: Add --update-instructions option for bundle layout - Homebrew cask template: postflight writes .aspire-update.json - Install scripts (sh/ps1): Delete .aspire-update.json after extraction so script-installed users retain self-update capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/Bundle.proj | 1 + eng/clipack/Common.projitems | 23 +++ eng/homebrew/aspire.rb.template | 7 + eng/scripts/get-aspire-cli.ps1 | 9 + eng/scripts/get-aspire-cli.sh | 5 + .../Commands/UpdateCommandTests.cs | 155 ++++++++++++++++++ .../CliUpdateNotificationServiceTests.cs | 58 +++++++ tools/CreateLayout/Program.cs | 26 +++ 8 files changed, 284 insertions(+) diff --git a/eng/Bundle.proj b/eng/Bundle.proj index 626184870d4..b34c2bd3b33 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -118,6 +118,7 @@ <_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/')) <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --verbose --archive + <_CreateLayoutArgs Condition="'$(UpdateInstructions)' != ''">$(_CreateLayoutArgs) --update-instructions "$(UpdateInstructions)" diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 5e67c75bbc5..9bf116f65cd 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -6,6 +6,12 @@ zip tar.gz + + winget upgrade Microsoft.Aspire + brew upgrade --cask aspire + true @@ -37,6 +43,23 @@ + + + + <_AspireUpdateJsonContent>{"selfUpdateDisabled":true,"updateInstructions":"$(CliUpdateInstructions)"} + + + + + <_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz + <_CliChannelArg Condition="'$(CliChannel)' != ''">/p:CliChannel=$(CliChannel) - + diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 9bf116f65cd..64038f636e5 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -12,6 +12,10 @@ winget upgrade Microsoft.Aspire brew upgrade --cask aspire + + stable + true @@ -77,6 +81,8 @@ + + diff --git a/localhive.ps1 b/localhive.ps1 index b5f9693280a..b7f7e329a1a 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -270,7 +270,7 @@ if (-not $SkipBundle) { $skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' } Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..." - $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix") + $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix", "/p:CliChannel=pr") if (-not $NativeAot) { $buildArgs += '/p:SkipNativeBuild=true' } @@ -376,10 +376,7 @@ if (-not $SkipCli) { $installedCliPath = Join-Path $cliBinDir $cliExeName Write-Log "Aspire CLI installed to: $installedCliPath" - - # Set the channel to the local hive so templates and packages resolve from it - & $installedCliPath config set channel $Name -g 2>$null - Write-Log "Set global channel to '$Name'" + Write-Log "CLI has embedded channel 'pr' - hive packages at $aspireRoot\packages\" # Check if the bin directory is in PATH $pathSeparator = [System.IO.Path]::PathSeparator diff --git a/localhive.sh b/localhive.sh index 859865339ac..c1ec857a4b1 100755 --- a/localhive.sh +++ b/localhive.sh @@ -246,10 +246,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then log "Building bundle (aspire-managed + DCP + native AOT CLI)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:CliChannel=pr" else log "Building bundle (aspire-managed + DCP)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" "/p:CliChannel=pr" fi if [[ $? -ne 0 ]]; then error "Bundle build failed." @@ -318,12 +318,7 @@ if [[ $SKIP_CLI -eq 0 ]]; then chmod +x "$CLI_BIN_DIR/aspire" log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" - - if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then - log "Set global channel to '$HIVE_NAME'" - else - warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" - fi + log "CLI has embedded channel 'pr' — hive packages at $ASPIRE_ROOT/packages/" # Check if the bin directory is in PATH if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index a3633f025f6..062798994a3 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -24,8 +24,20 @@ $(DefineConstants);CLI true false + + + + + + <_Parameter1>CliChannel + <_Parameter2>$(CliChannel) + + + true diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index fc6f1db8282..3b7a2b0ec09 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -94,8 +94,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var source = parseResult.GetValue(s_sourceOption); // For non-.NET projects, read the channel from the local Aspire configuration if available. - // Unlike .NET projects which have a nuget.config, polyglot apphosts persist the channel - // in aspire.config.json (or the legacy settings.json during migration). + // Unlike .NET projects which have a nuget.config, polyglot apphosts may persist the channel + // in the legacy settings.json. Falls back to the embedded channel baked into the binary. string? configuredChannel = null; if (project.LanguageId != KnownLanguageId.CSharp) { @@ -107,8 +107,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // have migrated. Tracked by https://github.com/microsoft/aspire/issues/15239 try { - configuredChannel = AspireConfigFile.Load(appHostDirectory)?.Channel - ?? AspireJsonConfiguration.Load(appHostDirectory)?.Channel; + configuredChannel = AspireJsonConfiguration.Load(appHostDirectory)?.Channel + ?? PackagingService.GetEmbeddedChannel(); } catch (JsonException ex) { diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 0eb3f96154e..e711c8843ee 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -736,10 +736,11 @@ private static bool IsSupportedTfm(string tfm) // Check if --channel option was provided (highest priority) var channelName = parseResult.GetValue(_channelOption); - // If no --channel option, check for global channel setting + // If no --channel option, check for global channel setting, then embedded channel if (string.IsNullOrEmpty(channelName)) { - channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? PackagingService.GetEmbeddedChannel(); } IEnumerable channels; diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index e0987fb5610..abdc4a1e85c 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -302,7 +302,8 @@ private async Task ResolveCliTemplateVersionAsync( var configuredChannelName = parseResult.GetValue(_channelOption); if (string.IsNullOrWhiteSpace(configuredChannelName)) { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? PackagingService.GetEmbeddedChannel(); } var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 54fc25b1046..4ab6ac269db 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -306,9 +306,20 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella { var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); - // If channel is not specified, always prompt the user to select one. - // This ensures they consciously choose a channel that will be saved to global settings - // for future 'aspire new' and 'aspire init' commands. + // If channel is not specified via argument, use the embedded channel baked into the binary at build time. + // This allows stable-channel binaries to self-update from the stable channel automatically, + // and daily-channel binaries to update from daily, without user interaction. + if (string.IsNullOrEmpty(channel)) + { + var embeddedChannel = PackagingService.GetEmbeddedChannel(); + if (!string.IsNullOrEmpty(embeddedChannel) && + !string.Equals(embeddedChannel, "pr", StringComparison.OrdinalIgnoreCase)) + { + channel = embeddedChannel; + } + } + + // If still not specified, prompt the user to select one. if (string.IsNullOrEmpty(channel)) { var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration); @@ -341,20 +352,6 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella // Extract and update to $HOME/.aspire/bin await ExtractAndUpdateAsync(archivePath, cancellationToken); - // Save the selected channel to global settings for future use with 'aspire new' and 'aspire init' - // For stable channel, clear the setting to leave it blank (like the install scripts do) - // For other channels (staging, daily), save the channel name - if (string.Equals(channel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase)) - { - await _configurationService.DeleteConfigurationAsync("channel", isGlobal: true, cancellationToken); - _logger.LogDebug("Cleared global channel setting for stable channel"); - } - else - { - await _configurationService.SetConfigurationAsync("channel", channel, isGlobal: true, cancellationToken); - _logger.LogDebug("Saved global channel setting: {Channel}", channel); - } - return 0; } catch (OperationCanceledException) diff --git a/src/Aspire.Cli/Configuration/AspireConfigFile.cs b/src/Aspire.Cli/Configuration/AspireConfigFile.cs index fb909e01437..397f980fa7c 100644 --- a/src/Aspire.Cli/Configuration/AspireConfigFile.cs +++ b/src/Aspire.Cli/Configuration/AspireConfigFile.cs @@ -24,7 +24,6 @@ namespace Aspire.Cli.Configuration; /// { /// "appHost": { "path": "app.ts", "language": "typescript/nodejs" }, /// "sdk": { "version": "9.2.0" }, -/// "channel": "stable", /// "features": { "polyglotSupportEnabled": true }, /// "profiles": { /// "default": { @@ -81,13 +80,6 @@ public string? SdkVersion set => (Sdk ??= new AspireConfigSdk()).Version = value; } - /// - /// Aspire channel for package resolution. - /// - [JsonPropertyName("channel")] - [Description("The Aspire channel to use for package resolution (e.g., \"stable\", \"preview\", \"staging\", \"daily\"). Used by aspire add to determine which NuGet feed to use.")] - public string? Channel { get; set; } - /// /// Feature flags. /// @@ -109,6 +101,14 @@ public string? SdkVersion [Description("Package references for non-first-class languages. Key is package name, value is version. A value ending in \".csproj\" is treated as a project reference.")] public Dictionary? Packages { get; set; } + /// + /// Captures any additional JSON properties not mapped to a typed member. + /// Ensures forward/backward compatibility when the file contains properties + /// from older or newer CLI versions (e.g., the former "channel" property). + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + /// /// Loads aspire.config.json from the specified directory. /// @@ -400,7 +400,8 @@ public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dic config.Sdk = new AspireConfigSdk { Version = settings.SdkVersion }; } - config.Channel = settings.Channel; + // Note: Channel is no longer migrated — it's now embedded in the binary + // via [assembly: AssemblyMetadata("CliChannel", "...")]. config.Features = settings.Features; config.Packages = settings.Packages; } diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 646661bd52e..00bca067c78 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -16,6 +16,20 @@ internal interface IPackagingService internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration) : IPackagingService { + /// + /// Reads the distribution channel baked into the binary at build time via + /// [assembly: AssemblyMetadata("CliChannel", "...")]. + /// Returns null for dev/inner-loop builds that have no embedded channel. + /// + internal static string? GetEmbeddedChannel() + { + var value = Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "CliChannel")?.Value; + + return string.IsNullOrEmpty(value) ? null : value; + } + public Task> GetChannelsAsync(CancellationToken cancellationToken = default) { var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache); diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 36290523aea..034a9db439d 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -329,7 +329,8 @@ private XDocument CreateProjectFile(IEnumerable integratio if (string.IsNullOrEmpty(configuredChannelName)) { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? PackagingService.GetEmbeddedChannel(); } // Resolve channel sources and add them via RestoreAdditionalProjectSources diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2fc509f3743..b340868a27c 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -361,12 +361,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken return (Success: true, Output: prepareOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: needsCodeGen); }, emoji: KnownEmojis.Gear); - // Save the channel to settings if available (config already has SdkVersion) - if (buildResult.ChannelName is not null) - { - config.Channel = buildResult.ChannelName; - SaveConfiguration(config, directory); - } + // Channel is now embedded in the binary via assembly metadata — no need to save. if (!buildResult.Success) { @@ -1124,11 +1119,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex { config.SdkVersion = newSdkVersion; } - // Update channel if it's an explicit channel (not the implicit/default one) - if (context.Channel.Type == Packaging.PackageChannelType.Explicit) - { - config.Channel = context.Channel.Name; - } + // Update channel is now embedded in the binary — don't persist to config. foreach (var (packageId, _, newVersion) in updates) { config.AddOrUpdatePackage(packageId, newVersion); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 39e25281e21..dae778fcd7f 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -330,7 +330,7 @@ internal static string GenerateIntegrationProjectFile( } /// - /// Resolves the configured channel name from local settings.json or global config. + /// Resolves the configured channel name from local settings.json, global config, or embedded channel. /// private async Task ResolveChannelNameAsync(CancellationToken cancellationToken) { @@ -338,10 +338,11 @@ internal static string GenerateIntegrationProjectFile( var localConfig = AspireJsonConfiguration.Load(_appDirectoryPath); var channelName = localConfig?.Channel; - // Fall back to global config + // Fall back to global config, then embedded channel if (string.IsNullOrEmpty(channelName)) { - channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? PackagingService.GetEmbeddedChannel(); } if (!string.IsNullOrEmpty(channelName)) diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index b7370f3e9a7..6e5190c6944 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -146,10 +146,6 @@ await GenerateCodeViaRpcAsync( } config.Profiles = profiles; - if (prepareResult.ChannelName is not null) - { - config.Channel = prepareResult.ChannelName; - } config.AppHost ??= new AspireConfigAppHost(); config.AppHost.Path ??= language.AppHostFileName; config.AppHost.Language = language.LanguageId; diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 30380beed72..20ff5a2cf9b 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -626,10 +626,11 @@ private async Task GetOutputPathAsync(TemplateInputs inputs, Func channels; diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index e6075182e9e..82d66730a04 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -63,7 +63,8 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, st { if (string.IsNullOrWhiteSpace(channelName)) { - channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); + channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? PackagingService.GetEmbeddedChannel(); } if (string.IsNullOrWhiteSpace(channelName)) diff --git a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs index f7556f11b89..604dc573412 100644 --- a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs @@ -36,7 +36,7 @@ public void Load_ReturnsConfig_WhenFileIsValid() Assert.NotNull(result); Assert.Equal("MyApp/MyApp.csproj", result.AppHost?.Path); - Assert.Equal("daily", result.Channel); + // "channel" is no longer a typed property — it's preserved via ExtensionData for backward compat } [Fact] @@ -59,7 +59,7 @@ public void Load_ReturnsConfig_WhenFileContainsJsonComments() Assert.NotNull(result); Assert.Equal("MyApp/MyApp.csproj", result.AppHost?.Path); - Assert.Equal("stable", result.Channel); + // "channel" is no longer a typed property — it's preserved via ExtensionData for backward compat } [Fact] @@ -120,7 +120,6 @@ public void Load_ReturnsEmptyConfig_WhenFileIsEmptyObject() Assert.NotNull(result); Assert.Null(result.AppHost); - Assert.Null(result.Channel); } [Fact] @@ -130,8 +129,7 @@ public void Save_CreatesFileWithExpectedContent() var config = new AspireConfigFile { - AppHost = new AspireConfigAppHost { Path = "src/AppHost/AppHost.csproj" }, - Channel = "daily" + AppHost = new AspireConfigAppHost { Path = "src/AppHost/AppHost.csproj" } }; config.Save(workspace.WorkspaceRoot.FullName); @@ -141,7 +139,6 @@ public void Save_CreatesFileWithExpectedContent() var content = File.ReadAllText(filePath); Assert.Contains("src/AppHost/AppHost.csproj", content); - Assert.Contains("daily", content); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs index f0ff9161d10..0e31e1eba94 100644 --- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs @@ -267,10 +267,9 @@ public async Task SetConfigurationAsync_ChannelWithBooleanLikeValue_StaysAsStrin Assert.Equal(JsonValueKind.String, node!.GetValueKind()); Assert.Equal("true", node.GetValue()); - // Verify it round-trips correctly through AspireConfigFile.Load + // Verify it round-trips correctly through AspireConfigFile.Load (channel is now in ExtensionData) var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); Assert.NotNull(config); - Assert.Equal("true", config.Channel); } [Fact] From 4de8d8ed6bba12319f8b25e55afc366b75914e15 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 7 Apr 2026 22:24:37 -0400 Subject: [PATCH 4/6] Add tests for ExtensionData preservation, FromLegacy channel exclusion, and GetEmbeddedChannel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExtensionData round-trip: verify unknown JSON properties (like legacy 'channel') survive load → save → reload - ExtensionData preservation: verify unknown properties captured on load - ExtensionData null: verify no ExtensionData when all props are known - FromLegacy channel exclusion: verify channel is NOT migrated from legacy config (now embedded in binary instead) - FromLegacy other fields: verify all non-channel fields still migrate - GetEmbeddedChannel dev build: verify returns null when no assembly metadata is set (test/dev builds) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/AspireConfigFileTests.cs | 117 ++++++++++++++++++ .../Packaging/PackagingServiceTests.cs | 10 ++ 2 files changed, 127 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs index 604dc573412..dc288367157 100644 --- a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs @@ -666,4 +666,121 @@ public void LoadOrCreate_MigratesLegacy_LeavesWindowsRootedPathUnchanged() // On Windows, c:\ is rooted and should be left unchanged Assert.Equal("c:\\path\\apphost.ts", config.AppHost?.Path); } + + [Fact] + public void Load_PreservesUnknownProperties_InExtensionData() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + File.WriteAllText(configPath, """ + { + "appHost": { "path": "MyApp.csproj" }, + "channel": "daily", + "someCustomProperty": 42 + } + """); + + var result = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(result); + Assert.Equal("MyApp.csproj", result.AppHost?.Path); + Assert.NotNull(result.ExtensionData); + Assert.True(result.ExtensionData.ContainsKey("channel")); + Assert.Equal("daily", result.ExtensionData["channel"].GetString()); + Assert.True(result.ExtensionData.ContainsKey("someCustomProperty")); + Assert.Equal(42, result.ExtensionData["someCustomProperty"].GetInt32()); + } + + [Fact] + public void Save_Load_RoundTrips_ExtensionData() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + File.WriteAllText(configPath, """ + { + "appHost": { "path": "MyApp.csproj" }, + "channel": "stable", + "legacyFlag": true + } + """); + + // Load, then save, then reload — unknown properties must survive + var loaded = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + Assert.NotNull(loaded); + loaded.Save(workspace.WorkspaceRoot.FullName); + + var reloaded = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(reloaded); + Assert.Equal("MyApp.csproj", reloaded.AppHost?.Path); + Assert.NotNull(reloaded.ExtensionData); + Assert.Equal("stable", reloaded.ExtensionData["channel"].GetString()); + Assert.True(reloaded.ExtensionData["legacyFlag"].GetBoolean()); + } + + [Fact] + public void Load_ExtensionData_IsNull_WhenNoUnknownProperties() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + File.WriteAllText(configPath, """ + { + "appHost": { "path": "MyApp.csproj" } + } + """); + + var result = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(result); + Assert.Null(result.ExtensionData); + } + + [Fact] + public void FromLegacy_DoesNotMigrateChannel() + { + var legacy = new AspireJsonConfiguration + { + AppHostPath = "../MyApp/MyApp.csproj", + Channel = "daily", + SdkVersion = "9.2.0" + }; + + var result = AspireConfigFile.FromLegacy(legacy, null); + + // Channel should NOT be carried over — it's now embedded in the binary + Assert.Equal("9.2.0", result.Sdk?.Version); + Assert.Null(result.ExtensionData); + } + + [Fact] + public void FromLegacy_MigratesOtherFieldsWithoutChannel() + { + var features = new Dictionary { ["polyglotSupportEnabled"] = true }; + var packages = new Dictionary { ["Aspire.Hosting.Redis"] = "9.2.0" }; + var legacy = new AspireJsonConfiguration + { + AppHostPath = "../App/apphost.ts", + Language = "typescript/nodejs", + Channel = "stable", + SdkVersion = "9.2.0", + Features = features, + Packages = packages + }; + + var result = AspireConfigFile.FromLegacy(legacy, null); + + // All fields except channel should be migrated + Assert.NotNull(result.AppHost); + Assert.Equal("typescript/nodejs", result.AppHost.Language); + Assert.Equal("9.2.0", result.Sdk?.Version); + Assert.NotNull(result.Features); + Assert.True(result.Features["polyglotSupportEnabled"]); + Assert.NotNull(result.Packages); + Assert.Equal("9.2.0", result.Packages["Aspire.Hosting.Redis"]); + // Channel must not appear anywhere in the migrated config + Assert.Null(result.ExtensionData); + } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 7cf4720ef09..926f44ab165 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -913,4 +913,14 @@ private sealed class FakeNuGetPackageCacheWithPackages(List> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); } + + [Fact] + public void GetEmbeddedChannel_ReturnsNull_ForDevBuilds() + { + // In test/dev builds, no CliChannel assembly metadata is set, + // so GetEmbeddedChannel() should return null. + var channel = PackagingService.GetEmbeddedChannel(); + + Assert.Null(channel); + } } From 72346297b99dfbedf42f746761aaea134cc5699c Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 02:39:28 -0400 Subject: [PATCH 5/6] Fix config migration to preserve channel in ExtensionData FromLegacy() was skipping channel migration, breaking the GlobalMigration_PreservesAllValueTypes E2E test. Channel values from legacy globalsettings.json are now preserved in ExtensionData so 'aspire config get channel' continues to work after migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/AspireConfigFile.cs | 12 ++++++-- .../Configuration/AspireConfigFileTests.cs | 29 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Configuration/AspireConfigFile.cs b/src/Aspire.Cli/Configuration/AspireConfigFile.cs index 397f980fa7c..51af5e0e442 100644 --- a/src/Aspire.Cli/Configuration/AspireConfigFile.cs +++ b/src/Aspire.Cli/Configuration/AspireConfigFile.cs @@ -400,8 +400,16 @@ public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dic config.Sdk = new AspireConfigSdk { Version = settings.SdkVersion }; } - // Note: Channel is no longer migrated — it's now embedded in the binary - // via [assembly: AssemblyMetadata("CliChannel", "...")]. + // Preserve channel in extension data for backward compatibility. + // The typed Channel property was removed (channel is now embedded in the binary), + // but users who previously set a channel should keep it in their config file. + if (!string.IsNullOrEmpty(settings.Channel)) + { + config.ExtensionData ??= new Dictionary(); + using var doc = JsonDocument.Parse($"\"{settings.Channel}\""); + config.ExtensionData["channel"] = doc.RootElement.Clone(); + } + config.Features = settings.Features; config.Packages = settings.Packages; } diff --git a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs index dc288367157..8f265b45c0f 100644 --- a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs @@ -739,7 +739,7 @@ public void Load_ExtensionData_IsNull_WhenNoUnknownProperties() } [Fact] - public void FromLegacy_DoesNotMigrateChannel() + public void FromLegacy_PreservesChannelInExtensionData() { var legacy = new AspireJsonConfiguration { @@ -750,13 +750,15 @@ public void FromLegacy_DoesNotMigrateChannel() var result = AspireConfigFile.FromLegacy(legacy, null); - // Channel should NOT be carried over — it's now embedded in the binary + // Channel should be preserved in ExtensionData for backward compatibility Assert.Equal("9.2.0", result.Sdk?.Version); - Assert.Null(result.ExtensionData); + Assert.NotNull(result.ExtensionData); + Assert.True(result.ExtensionData.ContainsKey("channel")); + Assert.Equal("daily", result.ExtensionData["channel"].GetString()); } [Fact] - public void FromLegacy_MigratesOtherFieldsWithoutChannel() + public void FromLegacy_MigratesAllFieldsIncludingChannel() { var features = new Dictionary { ["polyglotSupportEnabled"] = true }; var packages = new Dictionary { ["Aspire.Hosting.Redis"] = "9.2.0" }; @@ -772,7 +774,7 @@ public void FromLegacy_MigratesOtherFieldsWithoutChannel() var result = AspireConfigFile.FromLegacy(legacy, null); - // All fields except channel should be migrated + // All fields should be migrated Assert.NotNull(result.AppHost); Assert.Equal("typescript/nodejs", result.AppHost.Language); Assert.Equal("9.2.0", result.Sdk?.Version); @@ -780,7 +782,22 @@ public void FromLegacy_MigratesOtherFieldsWithoutChannel() Assert.True(result.Features["polyglotSupportEnabled"]); Assert.NotNull(result.Packages); Assert.Equal("9.2.0", result.Packages["Aspire.Hosting.Redis"]); - // Channel must not appear anywhere in the migrated config + // Channel preserved in extension data + Assert.NotNull(result.ExtensionData); + Assert.Equal("stable", result.ExtensionData["channel"].GetString()); + } + + [Fact] + public void FromLegacy_NoChannelDoesNotCreateExtensionData() + { + var legacy = new AspireJsonConfiguration + { + AppHostPath = "../MyApp/MyApp.csproj", + SdkVersion = "9.2.0" + }; + + var result = AspireConfigFile.FromLegacy(legacy, null); + Assert.Null(result.ExtensionData); } } From 82e3c8a9c4d908bb8034c5dee8a02b8940ba5226 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 8 Apr 2026 17:25:09 -0400 Subject: [PATCH 6/6] Fix AddCommand to use IConfigurationService for channel resolution AddCommand was reading the channel from the legacy AspireJsonConfiguration (.aspire/settings.json) while InitCommand correctly used the new IConfigurationService. This caused AddCommand to ignore channels set via 'aspire config set channel -g', falling back to the embedded 'stable' channel instead of the configured PR hive channel. Now AddCommand reads the channel from IConfigurationService, consistent with InitCommand (line 742) and other commands that use the new config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 3b7a2b0ec09..0a2e15fc422 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -28,6 +28,7 @@ internal sealed class AddCommand : BaseCommand private readonly IDotNetSdkInstaller _sdkInstaller; private readonly ICliHostEnvironment _hostEnvironment; private readonly IAppHostProjectFactory _projectFactory; + private readonly IConfigurationService _configurationService; private static readonly Argument s_integrationArgument = new("integration") { @@ -44,7 +45,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.SourceArgumentDescription }; - public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) + public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfigurationService configurationService) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _packagingService = packagingService; @@ -53,6 +54,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _projectFactory = projectFactory; + _configurationService = configurationService; Arguments.Add(s_integrationArgument); Options.Add(s_appHostOption); @@ -93,9 +95,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var source = parseResult.GetValue(s_sourceOption); - // For non-.NET projects, read the channel from the local Aspire configuration if available. - // Unlike .NET projects which have a nuget.config, polyglot apphosts may persist the channel - // in the legacy settings.json. Falls back to the embedded channel baked into the binary. + // For non-.NET projects, read the channel from the configuration service, + // which supports both the global config (aspire config set) and legacy settings. + // Falls back to the embedded channel baked into the binary. string? configuredChannel = null; if (project.LanguageId != KnownLanguageId.CSharp) { @@ -103,11 +105,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var isProjectReferenceMode = AspireRepositoryDetector.DetectRepositoryRoot(appHostDirectory) is not null; if (!isProjectReferenceMode) { - // TODO: Remove legacy AspireJsonConfiguration fallback once confident most users - // have migrated. Tracked by https://github.com/microsoft/aspire/issues/15239 try { - configuredChannel = AspireJsonConfiguration.Load(appHostDirectory)?.Channel + configuredChannel = await _configurationService.GetConfigurationAsync("channel", cancellationToken) ?? PackagingService.GetEmbeddedChannel(); } catch (JsonException ex)