diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index 574848912e4..8032f5275fc 100644 --- a/src/Aspire.Cli/Commands/BaseCommand.cs +++ b/src/Aspire.Cli/Commands/BaseCommand.cs @@ -51,7 +51,11 @@ protected BaseCommand(string name, string description, IFeatures features, ICliU var exitCode = await ExecuteAsync(parseResult, cancellationToken); - if (UpdateNotificationsEnabled && features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true)) + if (AutoUpdater.AppliedVersion is not null) + { + interactionService.DisplayMessage(KnownEmojis.Rocket, $"Auto-updated to version {AutoUpdater.AppliedVersion}. Changes take effect on next run."); + } + else if (UpdateNotificationsEnabled && features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true)) { try { diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 839f6d65a40..ab491eca6a9 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -89,19 +89,7 @@ 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); - } + private static bool IsRunningAsDotNetTool() => CliUpdateHelper.IsRunningAsDotNetTool(); protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { @@ -340,7 +328,7 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c throw new InvalidOperationException($"Unable to determine installation directory from: {currentExePath}"); } - var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire"; + var exeName = CliUpdateHelper.ExeName; var targetExePath = Path.Combine(installDir, exeName); var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName; @@ -357,33 +345,16 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); } - // Backup current executable if it exists - var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var backupPath = $"{targetExePath}.old.{unixTimestamp}"; - if (File.Exists(targetExePath)) - { - InteractionService.DisplayMessage(KnownEmojis.FloppyDisk, "Backing up current CLI..."); - _logger.LogDebug("Creating backup: {BackupPath}", backupPath); + // Backup current executable and replace with new one + InteractionService.DisplayMessage(KnownEmojis.FloppyDisk, "Backing up current CLI..."); - // Clean up old backup files - CleanupOldBackupFiles(targetExePath); + // Clean up old backup files first + CliUpdateHelper.CleanupOldBackupFiles(targetExePath); - // Rename current executable to .old.[timestamp] - File.Move(targetExePath, backupPath); - } + var backupPath = CliUpdateHelper.ReplaceExecutable(targetExePath, newExePath); try { - // Copy new executable to install location - InteractionService.DisplayMessage(KnownEmojis.Wrench, $"Installing new CLI to {installDir}..."); - File.Copy(newExePath, targetExePath, overwrite: true); - - // On Unix systems, ensure the executable bit is set - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - SetExecutablePermission(targetExePath); - } - // Test the new executable and display its version _logger.LogDebug("Testing new CLI executable and displaying version"); var newVersion = await GetNewVersionAsync(targetExePath, cancellationToken); @@ -393,7 +364,7 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c } // If we get here, the update was successful, clean up old backups - CleanupOldBackupFiles(targetExePath); + CliUpdateHelper.CleanupOldBackupFiles(targetExePath); // The new binary will extract its embedded bundle on first run via EnsureExtractedAsync. // No proactive extraction needed — the payload is inside the new binary's embedded resources, @@ -407,9 +378,9 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c } catch { - // If anything goes wrong, restore the backup + // If verification fails, restore the backup _logger.LogWarning("Update failed, restoring backup"); - if (File.Exists(backupPath)) + if (backupPath is not null && File.Exists(backupPath)) { if (File.Exists(targetExePath)) { @@ -451,23 +422,6 @@ private static bool IsInPath(string directory) : StringComparison.Ordinal)); } - private void SetExecutablePermission(string filePath) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - try - { - var mode = File.GetUnixFileMode(filePath); - mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - File.SetUnixFileMode(filePath, mode); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to set executable permission on {FilePath}", filePath); - } - } - } - private async Task GetNewVersionAsync(string exePath, CancellationToken cancellationToken) { try @@ -489,14 +443,14 @@ private void SetExecutablePermission(string filePath) var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken); - + if (process.ExitCode == 0) { var version = output.Trim(); InteractionService.DisplaySuccess($"Updated to version: {version}"); return version; } - + return null; } catch @@ -505,38 +459,7 @@ private void SetExecutablePermission(string filePath) } } - internal void CleanupOldBackupFiles(string targetExePath) - { - try - { - var directory = Path.GetDirectoryName(targetExePath); - if (string.IsNullOrEmpty(directory)) - { - return; - } - - var exeName = Path.GetFileName(targetExePath); - var searchPattern = $"{exeName}.old.*"; - - var oldBackupFiles = Directory.GetFiles(directory, searchPattern); - foreach (var backupFile in oldBackupFiles) - { - try - { - File.Delete(backupFile); - _logger.LogDebug("Deleted old backup file: {BackupFile}", backupFile); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to delete old backup file: {BackupFile}", backupFile); - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to cleanup old backup files for: {TargetExePath}", targetExePath); - } - } + internal static void CleanupOldBackupFiles(string targetExePath) => CliUpdateHelper.CleanupOldBackupFiles(targetExePath); private void CleanupDirectory(string directory) { diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 0531317439b..870b0549147 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -29,6 +29,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotGo => "experimentalPolyglot:go"; public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled"; + public static string AutoUpdateEnabled => "autoUpdateEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -100,6 +101,11 @@ internal static class KnownFeatures [RunningInstanceDetectionEnabled] = new( RunningInstanceDetectionEnabled, "Enable or disable detection of already running Aspire instances to prevent conflicts", + DefaultValue: true), + + [AutoUpdateEnabled] = new( + AutoUpdateEnabled, + "Enable or disable automatic background updates of the Aspire CLI to the latest version", DefaultValue: true) }; diff --git a/src/Aspire.Cli/NuGet/NuGetPackagePrefetcher.cs b/src/Aspire.Cli/NuGet/CliBackgroundService.cs similarity index 60% rename from src/Aspire.Cli/NuGet/NuGetPackagePrefetcher.cs rename to src/Aspire.Cli/NuGet/CliBackgroundService.cs index 4eec1ff5010..08ae6282f80 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackagePrefetcher.cs +++ b/src/Aspire.Cli/NuGet/CliBackgroundService.cs @@ -10,7 +10,17 @@ namespace Aspire.Cli.NuGet; -internal sealed class NuGetPackagePrefetcher(ILogger logger, CliExecutionContext executionContext, IFeatures features, IPackagingService packagingService, ICliUpdateNotifier cliUpdateNotifier) : BackgroundService +/// +/// Background service that prefetches NuGet package metadata and triggers CLI auto-updates. +/// +internal sealed class CliBackgroundService( + ILogger logger, + CliExecutionContext executionContext, + IFeatures features, + IPackagingService packagingService, + ICliUpdateNotifier cliUpdateNotifier, + ICliHostEnvironment hostEnvironment, + IConfigurationService configurationService) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -45,29 +55,70 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) }, stoppingToken); } - // Prefetch CLI packages if needed + // Prefetch CLI packages and trigger auto-update if needed if (shouldPrefetchCli) { _ = Task.Run(async () => { - if (features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true)) + try { - try - { - await cliUpdateNotifier.CheckForCliUpdatesAsync( - workingDirectory: executionContext.WorkingDirectory, - cancellationToken: stoppingToken - ); - } - catch (System.Exception ex) - { - logger.LogDebug(ex, "Non-fatal error while prefetching CLI packages. This is not critical to the operation of the CLI."); - } + await cliUpdateNotifier.CheckForCliUpdatesAsync( + workingDirectory: executionContext.WorkingDirectory, + cancellationToken: stoppingToken + ); + + // Trigger auto-update if an update is available + await TryTriggerAutoUpdateAsync(command, stoppingToken); + } + catch (System.Exception ex) + { + logger.LogDebug(ex, "Non-fatal error while prefetching CLI packages. This is not critical to the operation of the CLI."); } }, stoppingToken); } } + private async Task TryTriggerAutoUpdateAsync(SystemCommand? command, CancellationToken cancellationToken) + { + try + { + if (!cliUpdateNotifier.IsUpdateAvailable()) + { + return; + } + + // Skip auto-update for 'aspire update' command (user is managing updates) + if (command?.Name is "update") + { + return; + } + + if (!AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext)) + { + return; + } + + // Determine which channel to use for download + var channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken) ?? PackageChannelNames.Stable; + var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase)); + + if (channel?.CliDownloadBaseUrl is null) + { + return; + } + + var newerVersion = cliUpdateNotifier.GetNewerVersionString(); + AutoUpdater.SpawnBackgroundDownload(channel.CliDownloadBaseUrl, newerVersion); + + logger.LogDebug("Spawned background auto-update process for channel {Channel}", channelName); + } + catch (System.Exception ex) + { + logger.LogDebug(ex, "Non-fatal error while triggering auto-update."); + } + } + private async Task WaitForCommandSelectionAsync(CancellationToken cancellationToken) { try diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 80ce4b9bed0..854d9ef8dc4 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -278,8 +278,8 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar return sp.GetRequiredService(); }); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); @@ -574,6 +574,15 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; + // Handle internal auto-update command (runs in a detached background process, no DI needed) + if (args.Length >= 2 && args[0] == "internal-auto-update") + { + return await Utils.AutoUpdater.SilentDownloadAndStageAsync(args[1], args.Length >= 3 ? args[2] : null); + } + + // Apply staged update if available (before normal startup) + Utils.AutoUpdater.AppliedVersion = Utils.AutoUpdater.TryApplyStagedUpdate(); + using var app = await BuildApplicationAsync(args); await app.StartAsync().ConfigureAwait(false); diff --git a/src/Aspire.Cli/Utils/AutoUpdater.cs b/src/Aspire.Cli/Utils/AutoUpdater.cs new file mode 100644 index 00000000000..b61354ec995 --- /dev/null +++ b/src/Aspire.Cli/Utils/AutoUpdater.cs @@ -0,0 +1,270 @@ +// 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.Configuration; +using Aspire.Cli.Processes; + +namespace Aspire.Cli.Utils; + +/// +/// Handles automatic background updates of the Aspire CLI. +/// +internal static class AutoUpdater +{ + private const string StagingDirectoryName = "staging"; + private const string VersionFileName = "version.txt"; + private const int DownloadTimeoutSeconds = 600; + private const int ChecksumTimeoutSeconds = 120; + + /// + /// Set when a staged update was applied at startup. + /// Read by BaseCommand to show the auto-update notification. + /// + public static string? AppliedVersion { get; set; } + + /// + /// Gets the path to the staging directory (~/.aspire/staging/). + /// + public static string GetStagingDirectory() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(homeDir, ".aspire", StagingDirectoryName); + } + + /// + /// Checks whether auto-update should run. + /// + public static bool ShouldAutoUpdate( + ICliHostEnvironment hostEnvironment, + IFeatures features, + CliExecutionContext executionContext) + { + // Skip if feature disabled via 'aspire config set features.autoUpdateEnabled false' + if (!features.IsFeatureEnabled(KnownFeatures.AutoUpdateEnabled, true)) + { + return false; + } + + // Skip in CI environments + if (hostEnvironment.IsRunningInCI) + { + return false; + } + + // Skip if disabled via env var: ASPIRE_CLI_AUTO_UPDATE=false + var autoUpdateEnv = executionContext.GetEnvironmentVariable("ASPIRE_CLI_AUTO_UPDATE"); + if (string.Equals(autoUpdateEnv, "false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Skip for dotnet tool installs (use 'dotnet tool update' instead) + if (CliUpdateHelper.IsRunningAsDotNetTool()) + { + return false; + } + + // Skip if staging already has a pending update + if (HasStagedUpdate()) + { + return false; + } + + return true; + } + + /// + /// Spawns a detached background process to download and stage the CLI update. + /// + public static void SpawnBackgroundDownload(string cliDownloadBaseUrl, string? version) + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return; + } + + try + { + var arguments = new List { "internal-auto-update", cliDownloadBaseUrl }; + if (version is not null) + { + arguments.Add(version); + } + + DetachedProcessLauncher.Start(processPath, arguments, Environment.CurrentDirectory); + } + catch + { + // Silently ignore spawn failures + } + } + + /// + /// Downloads the CLI archive and stages it for the next startup. + /// This runs in a standalone background process with no DI. + /// + public static async Task SilentDownloadAndStageAsync(string baseUrl, string? version) + { + try + { + var (archiveUrl, checksumUrl, archiveFilename) = CliUpdateHelper.GetDownloadUrls(baseUrl); + var exeName = CliUpdateHelper.ExeName; + + var stagingDir = GetStagingDirectory(); + + // If staging already has an update, skip + if (File.Exists(Path.Combine(stagingDir, exeName))) + { + return 0; + } + + var tempDir = Directory.CreateTempSubdirectory("aspire-auto-update").FullName; + + try + { + var archivePath = Path.Combine(tempDir, archiveFilename); + var checksumPath = Path.Combine(tempDir, $"{archiveFilename}.sha512"); + + // Download archive and checksum + using var httpClient = new HttpClient(); + + await CliUpdateHelper.DownloadFileAsync(httpClient, archiveUrl, archivePath, DownloadTimeoutSeconds); + await CliUpdateHelper.DownloadFileAsync(httpClient, checksumUrl, checksumPath, ChecksumTimeoutSeconds); + + // Validate checksum + await CliUpdateHelper.ValidateChecksumAsync(archivePath, checksumPath); + + // Extract archive + var extractDir = Path.Combine(tempDir, "extracted"); + await ArchiveHelper.ExtractAsync(archivePath, extractDir, CancellationToken.None); + + // Find the CLI exe in extracted files + var newExePath = Path.Combine(extractDir, exeName); + if (!File.Exists(newExePath)) + { + return 1; + } + + // Stage the new binary atomically: write to temp file, then rename + // This prevents concurrent processes from reading a partially-written binary + Directory.CreateDirectory(stagingDir); + var tempStagedPath = Path.Combine(stagingDir, $"{exeName}.tmp.{Environment.ProcessId}"); + try + { + File.Copy(newExePath, tempStagedPath, overwrite: true); + File.Move(tempStagedPath, Path.Combine(stagingDir, exeName), overwrite: true); + } + catch + { + // Clean up temp file on failure + try { File.Delete(tempStagedPath); } catch { } + throw; + } + + // Write version marker + if (version is not null) + { + await File.WriteAllTextAsync( + Path.Combine(stagingDir, VersionFileName), + version); + } + + return 0; + } + finally + { + // Clean up temp directory + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + } + catch + { + return 1; + } + } + + /// + /// Checks for and applies a staged update. Called very early in Program.Main before DI setup. + /// Uses the rename-to-.old trick for Windows locked file handling. + /// + /// The version that was applied, or null if no update was staged. + public static string? TryApplyStagedUpdate() + { + try + { + var stagingDir = GetStagingDirectory(); + var exeName = CliUpdateHelper.ExeName; + var stagedExePath = Path.Combine(stagingDir, exeName); + + if (!File.Exists(stagedExePath)) + { + return null; + } + + var currentExePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(currentExePath)) + { + return null; + } + + // Guard: ensure we're updating the aspire executable, not the dotnet host. + var currentExeFileName = Path.GetFileName(currentExePath); + if (!string.Equals(currentExeFileName, exeName, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Read version before we move files + string? version = null; + var versionFile = Path.Combine(stagingDir, VersionFileName); + if (File.Exists(versionFile)) + { + version = File.ReadAllText(versionFile).Trim(); + } + + try + { + CliUpdateHelper.ReplaceExecutable(currentExePath, stagedExePath); + } + catch + { + return null; + } + + // Clean up staging directory + try + { + Directory.Delete(stagingDir, recursive: true); + } + catch + { + // Ignore cleanup errors + } + + CliUpdateHelper.CleanupOldBackupFiles(currentExePath); + + return version; + } + catch + { + return null; + } + } + + /// + /// Checks if there is a staged update ready to apply. + /// + public static bool HasStagedUpdate() + { + var stagingDir = GetStagingDirectory(); + return File.Exists(Path.Combine(stagingDir, CliUpdateHelper.ExeName)); + } +} diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 16d6e8305c5..36244ad7abc 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Security.Cryptography; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; using Microsoft.Extensions.Logging; @@ -46,13 +43,7 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio var baseUrl = channel.CliDownloadBaseUrl; - var (os, arch) = DetectPlatform(); - var runtimeIdentifier = $"{os}-{arch}"; - var extension = os == "win" ? "zip" : "tar.gz"; - var archiveFilename = $"aspire-cli-{runtimeIdentifier}.{extension}"; - var checksumFilename = $"{archiveFilename}.sha512"; - var archiveUrl = $"{baseUrl}/{archiveFilename}"; - var checksumUrl = $"{baseUrl}/{checksumFilename}"; + var (archiveUrl, checksumUrl, archiveFilename) = CliUpdateHelper.GetDownloadUrls(baseUrl); // Create temp directory for download var tempDir = Directory.CreateTempSubdirectory("aspire-cli-download").FullName; @@ -60,24 +51,24 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio try { var archivePath = Path.Combine(tempDir, archiveFilename); - var checksumPath = Path.Combine(tempDir, checksumFilename); + var checksumPath = Path.Combine(tempDir, $"{archiveFilename}.sha512"); // Download archive _ = await interactionService.ShowStatusAsync($"Downloading Aspire CLI from: {archiveUrl}", async () => { logger.LogDebug("Downloading archive from {Url} to {Path}", archiveUrl, archivePath); - await DownloadFileAsync(archiveUrl, archivePath, ArchiveDownloadTimeoutSeconds, cancellationToken); + await CliUpdateHelper.DownloadFileAsync(s_httpClient, archiveUrl, archivePath, ArchiveDownloadTimeoutSeconds, cancellationToken); // Download checksum logger.LogDebug("Downloading checksum from {Url} to {Path}", checksumUrl, checksumPath); - await DownloadFileAsync(checksumUrl, checksumPath, ChecksumDownloadTimeoutSeconds, cancellationToken); + await CliUpdateHelper.DownloadFileAsync(s_httpClient, checksumUrl, checksumPath, ChecksumDownloadTimeoutSeconds, cancellationToken); return 0; // Return dummy value for ShowStatusAsync }); // Validate checksum interactionService.DisplayMessage(KnownEmojis.CheckMark, "Validating downloaded file..."); - await ValidateChecksumAsync(archivePath, checksumPath, cancellationToken); + await CliUpdateHelper.ValidateChecksumAsync(archivePath, checksumPath, cancellationToken); interactionService.DisplaySuccess("Download completed successfully"); return archivePath; @@ -100,99 +91,4 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio } } - private static (string os, string arch) DetectPlatform() - { - var os = DetectOperatingSystem(); - var arch = DetectArchitecture(); - return (os, arch); - } - - private static string DetectOperatingSystem() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "win"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - // Check if it's musl-based (Alpine, etc.) - try - { - var lddPath = "/usr/bin/ldd"; - if (File.Exists(lddPath)) - { - var psi = new ProcessStartInfo - { - FileName = lddPath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var process = Process.Start(psi); - if (process is not null) - { - var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (output.Contains("musl", StringComparison.OrdinalIgnoreCase)) - { - return "linux-musl"; - } - } - } - } - catch - { - // Fall back to regular linux - } - return "linux"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return "osx"; - } - else - { - throw new PlatformNotSupportedException($"Unsupported operating system: {RuntimeInformation.OSDescription}"); - } - } - - private static string DetectArchitecture() - { - var arch = RuntimeInformation.ProcessArchitecture; - return arch switch - { - Architecture.X64 => "x64", - Architecture.X86 => "x86", - Architecture.Arm64 => "arm64", - _ => throw new PlatformNotSupportedException($"Unsupported architecture: {arch}") - }; - } - - private static async Task DownloadFileAsync(string url, string outputPath, int timeoutSeconds, CancellationToken cancellationToken) - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); - - using var response = await s_httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token); - response.EnsureSuccessStatusCode(); - - await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); - await response.Content.CopyToAsync(fileStream, cts.Token); - } - - private static async Task ValidateChecksumAsync(string archivePath, string checksumPath, CancellationToken cancellationToken) - { - var expectedChecksum = (await File.ReadAllTextAsync(checksumPath, cancellationToken)).Trim().ToLowerInvariant(); - - using var sha512 = SHA512.Create(); - await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var hashBytes = await sha512.ComputeHashAsync(fileStream, cancellationToken); - var actualChecksum = Convert.ToHexString(hashBytes).ToLowerInvariant(); - - if (expectedChecksum != actualChecksum) - { - throw new InvalidOperationException($"Checksum validation failed. Expected: {expectedChecksum}, Actual: {actualChecksum}"); - } - } } diff --git a/src/Aspire.Cli/Utils/CliHostEnvironment.cs b/src/Aspire.Cli/Utils/CliHostEnvironment.cs index a8a378f705a..b31b9c1bb6a 100644 --- a/src/Aspire.Cli/Utils/CliHostEnvironment.cs +++ b/src/Aspire.Cli/Utils/CliHostEnvironment.cs @@ -25,6 +25,11 @@ internal interface ICliHostEnvironment /// Gets whether the host supports colors and ANSI codes. /// bool SupportsAnsi { get; } + + /// + /// Gets whether the CLI is running in a CI environment. + /// + bool IsRunningInCI { get; } } /// @@ -66,8 +71,14 @@ internal sealed class CliHostEnvironment : ICliHostEnvironment /// public bool SupportsAnsi { get; } + /// + /// Gets whether the CLI is running in a CI environment. + /// + public bool IsRunningInCI { get; } + public CliHostEnvironment(IConfiguration configuration, bool nonInteractive) { + IsRunningInCI = IsCI(configuration); // If --non-interactive is explicitly set, disable interactive input and output. // This takes precedence over all other settings including ASPIRE_PLAYGROUND. if (nonInteractive) diff --git a/src/Aspire.Cli/Utils/CliUpdateHelper.cs b/src/Aspire.Cli/Utils/CliUpdateHelper.cs new file mode 100644 index 00000000000..ded31024da6 --- /dev/null +++ b/src/Aspire.Cli/Utils/CliUpdateHelper.cs @@ -0,0 +1,242 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace Aspire.Cli.Utils; + +/// +/// Shared utilities for CLI update operations used by both interactive self-update and background auto-update. +/// +internal static class CliUpdateHelper +{ + /// + /// Gets the platform-appropriate executable name for the Aspire CLI. + /// + public static string ExeName => OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; + + /// + /// Detects the current platform's OS and architecture for download purposes. + /// + public static (string Os, string Arch) DetectPlatform() + { + var os = DetectOperatingSystem(); + var arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.X86 => "x86", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException($"Unsupported architecture: {RuntimeInformation.ProcessArchitecture}") + }; + return (os, arch); + } + + /// + /// Checks whether the CLI is running as a dotnet tool (vs a native binary). + /// + public static bool IsRunningAsDotNetTool() + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return false; + } + + var fileName = Path.GetFileNameWithoutExtension(processPath); + return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Replaces the current CLI executable with a new one using the backup-and-swap pattern. + /// Handles Windows locked-file workaround by renaming the running exe to .old.{timestamp}. + /// + /// The path to the backup file, or null if no backup was needed. + public static string? ReplaceExecutable(string currentExePath, string newExePath) + { + string? backupPath = null; + + // Backup current executable if it exists + if (File.Exists(currentExePath)) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + backupPath = $"{currentExePath}.old.{timestamp}"; + File.Move(currentExePath, backupPath); + } + + try + { + File.Copy(newExePath, currentExePath, overwrite: true); + + if (!OperatingSystem.IsWindows()) + { + SetExecutablePermission(currentExePath); + } + } + catch + { + // Rollback — restore backup + if (backupPath is not null) + { + try + { + if (File.Exists(currentExePath)) + { + File.Delete(currentExePath); + } + File.Move(backupPath, currentExePath); + } + catch + { + // Critical failure — both files may be in an inconsistent state + } + } + throw; + } + + return backupPath; + } + + /// + /// Sets executable permissions on a file (Unix only). + /// + public static void SetExecutablePermission(string filePath) + { + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(filePath); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(filePath, mode); + } + } + + /// + /// Removes old backup files matching the pattern {exeName}.old.* in the same directory. + /// + public static void CleanupOldBackupFiles(string targetExePath) + { + try + { + var directory = Path.GetDirectoryName(targetExePath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + var exeName = Path.GetFileName(targetExePath); + var searchPattern = $"{exeName}.old.*"; + + foreach (var backupFile in Directory.GetFiles(directory, searchPattern)) + { + try + { + File.Delete(backupFile); + } + catch + { + // Ignore — file may still be locked by another running instance + } + } + } + catch + { + // Ignore cleanup errors + } + } + + /// + /// Downloads a file from a URL to a local path with a timeout. + /// + public static async Task DownloadFileAsync(HttpClient httpClient, string url, string outputPath, int timeoutSeconds, CancellationToken cancellationToken = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream, cts.Token); + } + + /// + /// Validates a downloaded file against a SHA-512 checksum file. + /// + public static async Task ValidateChecksumAsync(string archivePath, string checksumPath, CancellationToken cancellationToken = default) + { + var expectedChecksum = (await File.ReadAllTextAsync(checksumPath, cancellationToken)).Trim().ToLowerInvariant(); + + using var sha512 = SHA512.Create(); + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var hashBytes = await sha512.ComputeHashAsync(fileStream, cancellationToken); + var actualChecksum = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + if (expectedChecksum != actualChecksum) + { + throw new InvalidOperationException($"Checksum validation failed. Expected: {expectedChecksum}, Actual: {actualChecksum}"); + } + } + + /// + /// Builds the download URLs for a CLI archive and its checksum. + /// + public static (string ArchiveUrl, string ChecksumUrl, string ArchiveFilename) GetDownloadUrls(string baseUrl) + { + var (os, arch) = DetectPlatform(); + var rid = $"{os}-{arch}"; + var ext = os == "win" ? "zip" : "tar.gz"; + var archiveFilename = $"aspire-cli-{rid}.{ext}"; + var checksumFilename = $"{archiveFilename}.sha512"; + return ($"{baseUrl}/{archiveFilename}", $"{baseUrl}/{checksumFilename}", archiveFilename); + } + + private static string DetectOperatingSystem() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + try + { + var lddPath = "/usr/bin/ldd"; + if (File.Exists(lddPath)) + { + var psi = new ProcessStartInfo + { + FileName = lddPath, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var process = Process.Start(psi); + if (process is not null) + { + var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (output.Contains("musl", StringComparison.OrdinalIgnoreCase)) + { + return "linux-musl"; + } + } + } + } + catch + { + // Fall back to regular linux + } + return "linux"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "osx"; + } + else + { + throw new PlatformNotSupportedException($"Unsupported operating system: {RuntimeInformation.OSDescription}"); + } + } +} diff --git a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs index 68fbdbcc4a7..a95bd95ce92 100644 --- a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs +++ b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs @@ -14,6 +14,7 @@ internal interface ICliUpdateNotifier Task CheckForCliUpdatesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken); void NotifyIfUpdateAvailable(); bool IsUpdateAvailable(); + string? GetNewerVersionString(); } internal class CliUpdateNotifier( @@ -75,6 +76,22 @@ public bool IsUpdateAvailable() return newerVersion is not null; } + public string? GetNewerVersionString() + { + if (_availablePackages is null) + { + return null; + } + + var currentVersion = GetCurrentVersion(); + if (currentVersion is null) + { + return null; + } + + return PackageUpdateHelpers.GetNewerVersion(logger, currentVersion, _availablePackages)?.ToString(); + } + /// /// Determines whether the Aspire CLI is running as a .NET tool or as a native binary. /// diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 2c228f9f058..99b25f1e8a0 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -94,7 +94,7 @@ public void CleanupOldBackupFiles_DeletesFilesMatchingPattern() var updateCommand = CreateUpdateCommand(workspace); // Act - updateCommand.CleanupOldBackupFiles(targetExePath); + UpdateCommand.CleanupOldBackupFiles(targetExePath); // Assert Assert.False(File.Exists(oldBackup1), "Old backup file should be deleted"); @@ -117,7 +117,7 @@ public void CleanupOldBackupFiles_HandlesInUseFilesGracefully() var updateCommand = CreateUpdateCommand(workspace); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(targetExePath); + UpdateCommand.CleanupOldBackupFiles(targetExePath); // On Windows, locked files cannot be deleted, so the file should still exist // On Mac/Linux, locked files can be deleted, so the file may be deleted @@ -140,7 +140,7 @@ public void CleanupOldBackupFiles_HandlesNonExistentDirectory() var updateCommand = CreateUpdateCommand(workspace); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(nonExistentPath); + UpdateCommand.CleanupOldBackupFiles(nonExistentPath); } [Fact] @@ -152,7 +152,7 @@ public void CleanupOldBackupFiles_HandlesEmptyDirectory() var updateCommand = CreateUpdateCommand(workspace); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(targetExePath); + UpdateCommand.CleanupOldBackupFiles(targetExePath); } private UpdateCommand CreateUpdateCommand(TemporaryWorkspace workspace) diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs index ff18dfaa44b..b5348adeb1d 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs @@ -7,7 +7,7 @@ namespace Aspire.Cli.Tests.NuGet; -public class NuGetPackagePrefetcherTests +public class CliBackgroundServiceTests { [Fact] public void CliExecutionContextSetsCommand() diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 78f7b45af99..8155f9afdb1 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -566,5 +566,6 @@ private sealed class FakeCliHostEnvironment(bool nonInteractive) : ICliHostEnvir public bool SupportsInteractiveInput => !nonInteractive; public bool SupportsInteractiveOutput => !nonInteractive; public bool SupportsAnsi => false; + public bool IsRunningInCI => false; } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs b/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs index bdac02e13c1..8fe118b4809 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs @@ -25,4 +25,9 @@ public bool IsUpdateAvailable() { return IsUpdateAvailableCallback?.Invoke() ?? false; } + + public string? GetNewerVersionString() + { + return null; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs b/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs new file mode 100644 index 00000000000..c54e002483b --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs @@ -0,0 +1,129 @@ +// 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.Configuration; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class AutoUpdaterTests +{ + [Fact] + public void ShouldAutoUpdate_ReturnsTrue_WhenAllConditionsMet() + { + var hostEnvironment = new FakeCliHostEnvironment(); + var features = new FakeFeatures(); + var executionContext = CreateExecutionContext(); + + var result = AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext); + + Assert.True(result); + } + + [Fact] + public void ShouldAutoUpdate_ReturnsFalse_WhenFeatureDisabled() + { + var hostEnvironment = new FakeCliHostEnvironment(); + var features = new FakeFeatures { AutoUpdateEnabled = false }; + var executionContext = CreateExecutionContext(); + + var result = AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext); + + Assert.False(result); + } + + [Fact] + public void ShouldAutoUpdate_ReturnsFalse_WhenRunningInCI() + { + var hostEnvironment = new FakeCliHostEnvironment { IsRunningInCI = true }; + var features = new FakeFeatures(); + var executionContext = CreateExecutionContext(); + + var result = AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext); + + Assert.False(result); + } + + [Fact] + public void ShouldAutoUpdate_ReturnsFalse_WhenEnvVarDisabled() + { + var hostEnvironment = new FakeCliHostEnvironment(); + var features = new FakeFeatures(); + var envVars = new Dictionary { ["ASPIRE_CLI_AUTO_UPDATE"] = "false" }; + var executionContext = CreateExecutionContext(envVars); + + var result = AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext); + + Assert.False(result); + } + + [Fact] + public void ShouldAutoUpdate_ReturnsTrue_WhenEnvVarNotSet() + { + var hostEnvironment = new FakeCliHostEnvironment(); + var features = new FakeFeatures(); + var envVars = new Dictionary(); + var executionContext = CreateExecutionContext(envVars); + + var result = AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext); + + Assert.True(result); + } + + [Fact] + public void GetStagingDirectory_ReturnsExpectedPath() + { + var stagingDir = AutoUpdater.GetStagingDirectory(); + + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var expected = Path.Combine(homeDir, ".aspire", "staging"); + + Assert.Equal(expected, stagingDir); + } + + [Fact] + public void HasStagedUpdate_DoesNotThrow_WhenCheckingStagingDirectory() + { + // Call HasStagedUpdate and ensure it can be invoked without throwing. + var result = AutoUpdater.HasStagedUpdate(); + + // The return value may vary depending on whether a staging directory exists. + // This test only verifies that a boolean is returned and no exception is thrown. + Assert.IsType(result); + } + + private static CliExecutionContext CreateExecutionContext(Dictionary? envVars = null) + { + var tempDir = new DirectoryInfo(Path.GetTempPath()); + return new CliExecutionContext( + workingDirectory: tempDir, + hivesDirectory: tempDir, + cacheDirectory: tempDir, + sdksDirectory: tempDir, + logsDirectory: tempDir, + logFilePath: Path.Combine(tempDir.FullName, "test.log"), + environmentVariables: envVars != null ? new Dictionary(envVars) : null); + } + + private sealed class FakeCliHostEnvironment : ICliHostEnvironment + { + public bool SupportsInteractiveInput => true; + public bool SupportsInteractiveOutput => true; + public bool SupportsAnsi => false; + public bool IsRunningInCI { get; set; } + } + + private sealed class FakeFeatures : IFeatures + { + public bool AutoUpdateEnabled { get; set; } = true; + + public bool IsFeatureEnabled(string featureFlag, bool defaultValue) + { + if (featureFlag == KnownFeatures.AutoUpdateEnabled) + { + return AutoUpdateEnabled; + } + return defaultValue; + } + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 305d4ff4781..e9292c4fdec 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -119,8 +119,8 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.BannerServiceFactory); services.AddSingleton(); services.AddSingleton(options.ProjectUpdaterFactory); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(options.AuxiliaryBackchannelMonitorFactory); services.AddSingleton(options.AgentEnvironmentDetectorFactory); services.AddSingleton(options.GitRepositoryFactory);