diff --git a/eng/Bundle.proj b/eng/Bundle.proj index 626184870d4..a3e4a3bd00f 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -89,9 +89,10 @@ <_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog <_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz + <_CliChannelArg Condition="'$(CliChannel)' != ''">/p:CliChannel=$(CliChannel) - + @@ -118,6 +119,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..64038f636e5 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -6,6 +6,16 @@ zip tar.gz + + winget upgrade Microsoft.Aspire + brew upgrade --cask aspire + + + stable + true @@ -37,6 +47,23 @@ + + + + <_AspireUpdateJsonContent>{"selfUpdateDisabled":true,"updateInstructions":"$(CliUpdateInstructions)"} + + + + + + diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index dbe07554fe3..b0383698a3c 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -13,5 +13,12 @@ cask "aspire" do binary "aspire" + postflight do + # Write .aspire-update.json next to the binary to disable self-update. + # Users should update via 'brew upgrade --cask aspire' instead. + config_file = staged_path.join(".aspire-update.json") + config_file.write('{"selfUpdateDisabled":true,"updateInstructions":"brew upgrade --cask aspire"}') + end + zap trash: "~/.aspire" end diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 index a16f6e1f102..7309952f8a1 100755 --- a/eng/scripts/get-aspire-cli.ps1 +++ b/eng/scripts/get-aspire-cli.ps1 @@ -764,6 +764,15 @@ function Expand-AspireCliArchive { Remove-OldCliBackupFiles -TargetExePath $targetExePath } + # Remove .aspire-update.json if present. This file disables self-update for + # package-manager installations (WinGet, Homebrew) but install-script users + # should retain self-update capability. + $aspireUpdateJson = Join-Path $DestinationPath ".aspire-update.json" + if (Test-Path $aspireUpdateJson) { + Remove-Item -Path $aspireUpdateJson -Force -ErrorAction SilentlyContinue + Write-Message "Removed .aspire-update.json (self-update remains enabled for script installs)" -Level Verbose + } + Write-Message "Successfully unpacked archive" -Level Verbose } catch { diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh index cc4a04d59b4..6ec7de2b96f 100755 --- a/eng/scripts/get-aspire-cli.sh +++ b/eng/scripts/get-aspire-cli.sh @@ -478,6 +478,11 @@ install_archive() { fi fi + # Remove .aspire-update.json if present. This file disables self-update for + # package-manager installations (WinGet, Homebrew) but install-script users + # should retain self-update capability. + rm -f "${destination_path}/.aspire-update.json" + say_verbose "Successfully installed archive" } 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..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 persist the channel - // in aspire.config.json (or the legacy settings.json during migration). + // 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,12 +105,10 @@ 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 = AspireConfigFile.Load(appHostDirectory)?.Channel - ?? AspireJsonConfiguration.Load(appHostDirectory)?.Channel; + configuredChannel = await _configurationService.GetConfigurationAsync("channel", cancellationToken) + ?? 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 665bd0f9208..4ab6ac269db 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, @@ -269,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); @@ -304,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..51af5e0e442 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,16 @@ public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dic config.Sdk = new AspireConfigSdk { Version = settings.SdkVersion }; } - config.Channel = settings.Channel; + // 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/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/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/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/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/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/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/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/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 6f622c9edc4..583677ed8c2 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -1093,3 +1093,158 @@ public Task> GetChannelsAsync(CancellationToken canc return Task.FromResult>(new[] { testChannel }); } } + +public class UpdateCommandSelfUpdateDisabledTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task UpdateCommand_SelfFlag_WhenSelfUpdateDisabled_ShowsDisabledMessageAndReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InstallationDetectorFactory = _ => new TestInstallationDetector + { + InstallationInfo = new InstallationInfo( + IsDotNetTool: false, + SelfUpdateDisabled: true, + UpdateInstructions: "brew upgrade --cask aspire") + }; + + options.InteractionServiceFactory = _ => new TestInteractionService(); + }); + + var provider = services.BuildServiceProvider(); + var interactionService = provider.GetRequiredService() as TestInteractionService; + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Contains(interactionService!.DisplayedMessages, + m => m.Message.Contains("Self-update is disabled")); + } + + [Fact] + public async Task UpdateCommand_PostProjectUpdate_WhenSelfUpdateDisabled_DoesNotPrompt() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var confirmCallbackInvoked = false; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InstallationDetectorFactory = _ => new TestInstallationDetector + { + InstallationInfo = new InstallationInfo( + IsDotNetTool: false, + SelfUpdateDisabled: true, + UpdateInstructions: "winget upgrade Microsoft.Aspire") + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) => + { + return Task.FromResult(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"))); + } + }; + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + ConfirmCallback = (prompt, defaultValue) => + { + confirmCallbackInvoked = true; + return false; + } + }; + + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + + options.ProjectUpdaterFactory = _ => new TestProjectUpdater() + { + UpdateProjectAsyncCallback = (projectFile, channel, cancellationToken) => + { + return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true }); + } + }; + + options.PackagingServiceFactory = _ => new TestPackagingService() + { + GetChannelsAsyncCallback = (cancellationToken) => + { + var stableChannel = PackageChannel.CreateExplicitChannel( + "stable", + PackageChannelQuality.Stable, + new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") }, + null!, + configureGlobalPackagesFolder: false, + cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); + return Task.FromResult>(new[] { stableChannel }); + } + }; + + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier() + { + IsUpdateAvailableCallback = () => true + }; + }); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --apphost AppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.False(confirmCallbackInvoked, "Confirm prompt should NOT be shown when self-update is disabled"); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task UpdateCommand_NoProjectFound_WhenSelfUpdateDisabled_ShowsInstructionsInsteadOfPrompt() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var confirmCallbackInvoked = false; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InstallationDetectorFactory = _ => new TestInstallationDetector + { + InstallationInfo = new InstallationInfo( + IsDotNetTool: false, + SelfUpdateDisabled: true, + UpdateInstructions: "brew upgrade --cask aspire") + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) => + { + throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound); + } + }; + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + ConfirmCallback = (prompt, defaultValue) => + { + confirmCallbackInvoked = true; + return false; + } + }; + + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + }); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.False(confirmCallbackInvoked, "Confirm prompt should NOT be shown when self-update is disabled"); + } +} diff --git a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs index f7556f11b89..8f265b45c0f 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] @@ -669,4 +666,138 @@ 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_PreservesChannelInExtensionData() + { + var legacy = new AspireJsonConfiguration + { + AppHostPath = "../MyApp/MyApp.csproj", + Channel = "daily", + SdkVersion = "9.2.0" + }; + + var result = AspireConfigFile.FromLegacy(legacy, null); + + // Channel should be preserved in ExtensionData for backward compatibility + Assert.Equal("9.2.0", result.Sdk?.Version); + Assert.NotNull(result.ExtensionData); + Assert.True(result.ExtensionData.ContainsKey("channel")); + Assert.Equal("daily", result.ExtensionData["channel"].GetString()); + } + + [Fact] + public void FromLegacy_MigratesAllFieldsIncludingChannel() + { + 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 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 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); + } } 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] 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); + } } 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..0606844e70e 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); }; }); @@ -274,9 +278,67 @@ public async Task NotifyIfUpdateAvailableAsync_WithEmptyPackages_DoesNotThrow() await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); service.NotifyIfUpdateAvailable(); } + + [Fact] + public async Task NotifyIfUpdateAvailable_WhenSelfUpdateDisabled_SuppressesNotification() + { + TaskCompletionSource suggestedVersionTcs = new(); + var notificationWasDisplayed = false; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => + { + configure.NuGetPackageCacheFactory = (sp) => + { + var cache = new TestNuGetPackageCache(); + cache.SetMockCliPackages([ + new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }, + ]); + + return cache; + }; + + configure.InteractionServiceFactory = (sp) => + { + var interactionService = new TestInteractionService(); + interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) => + { + notificationWasDisplayed = true; + }; + + return interactionService; + }; + + configure.InstallationDetectorFactory = _ => new TestInstallationDetector + { + InstallationInfo = new InstallationInfo( + IsDotNetTool: false, + SelfUpdateDisabled: true, + UpdateInstructions: "brew upgrade --cask aspire") + }; + + configure.CliUpdateNotifierFactory = (sp) => + { + var logger = sp.GetRequiredService>(); + var nuGetPackageCache = sp.GetRequiredService(); + var interactionService = sp.GetRequiredService(); + var installationDetector = sp.GetRequiredService(); + + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, interactionService, installationDetector); + }; + }); + + var provider = services.BuildServiceProvider(); + var notifier = provider.GetRequiredService(); + + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); + notifier.NotifyIfUpdateAvailable(); + + Assert.False(notificationWasDisplayed, "Update notification should be suppressed when self-update is disabled"); + } } -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); + } + } +} diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index b04ec44e7a1..f7b89805df9 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -54,6 +54,11 @@ public static async Task Main(string[] args) Description = "Enable verbose output" }; + var updateInstructionsOption = new Option("--update-instructions") + { + Description = "When set, writes .aspire-update.json to the layout root with self-update disabled and these instructions" + }; + var rootCommand = new RootCommand("CreateLayout - Build Aspire bundle layout for distribution"); rootCommand.Options.Add(outputOption); rootCommand.Options.Add(artifactsOption); @@ -61,6 +66,7 @@ public static async Task Main(string[] args) rootCommand.Options.Add(bundleVersionOption); rootCommand.Options.Add(archiveOption); rootCommand.Options.Add(verboseOption); + rootCommand.Options.Add(updateInstructionsOption); rootCommand.SetAction(async (parseResult, cancellationToken) => { @@ -70,12 +76,18 @@ public static async Task Main(string[] args) var version = parseResult.GetValue(bundleVersionOption)!; var createArchive = parseResult.GetValue(archiveOption); var verbose = parseResult.GetValue(verboseOption); + var updateInstructions = parseResult.GetValue(updateInstructionsOption); try { using var builder = new LayoutBuilder(outputPath, artifactsPath, rid, version, verbose); await builder.BuildAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(updateInstructions)) + { + builder.WriteAspireUpdateJson(updateInstructions); + } + if (createArchive) { await builder.CreateArchiveAsync().ConfigureAwait(false); @@ -365,6 +377,20 @@ public async Task CreateArchiveAsync() return null; } + public void WriteAspireUpdateJson(string updateInstructions) + { + var configPath = Path.Combine(_outputPath, ".aspire-update.json"); + var escapedInstructions = JsonEncodedText.Encode(updateInstructions); + var json = $$""" + { + "selfUpdateDisabled": true, + "updateInstructions": "{{escapedInstructions}}" + } + """; + File.WriteAllText(configPath, json); + Log($" Wrote .aspire-update.json with instructions: {updateInstructions}"); + } + private static void CopyDirectory(string source, string destination) { Directory.CreateDirectory(destination);