From f5635be2426d2a64a70ba8feee8d571386d25601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 08:49:44 +0000 Subject: [PATCH 01/14] Initial plan From e5442758269625d250be3288de6f31ae78eea1f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:03:17 +0000 Subject: [PATCH 02/14] Add aspire self update command implementation Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 3 + src/Aspire.Cli/Commands/SelfCommand.cs | 16 ++ src/Aspire.Cli/Commands/SelfUpdateCommand.cs | 250 +++++++++++++++++++ src/Aspire.Cli/Program.cs | 3 + src/Aspire.Cli/Utils/CliDownloader.cs | 189 ++++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 src/Aspire.Cli/Commands/SelfCommand.cs create mode 100644 src/Aspire.Cli/Commands/SelfUpdateCommand.cs create mode 100644 src/Aspire.Cli/Utils/CliDownloader.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 5c1ef692539..d8ed2b2a85d 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -30,6 +30,7 @@ public RootCommand( CacheCommand cacheCommand, ExecCommand execCommand, UpdateCommand updateCommand, + SelfCommand selfCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -45,6 +46,7 @@ public RootCommand( ArgumentNullException.ThrowIfNull(deployCommand); ArgumentNullException.ThrowIfNull(updateCommand); ArgumentNullException.ThrowIfNull(execCommand); + ArgumentNullException.ThrowIfNull(selfCommand); ArgumentNullException.ThrowIfNull(extensionInternalCommand); ArgumentNullException.ThrowIfNull(featureFlags); ArgumentNullException.ThrowIfNull(interactionService); @@ -105,6 +107,7 @@ public RootCommand( Subcommands.Add(cacheCommand); Subcommands.Add(deployCommand); Subcommands.Add(updateCommand); + Subcommands.Add(selfCommand); Subcommands.Add(extensionInternalCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) diff --git a/src/Aspire.Cli/Commands/SelfCommand.cs b/src/Aspire.Cli/Commands/SelfCommand.cs new file mode 100644 index 00000000000..f4356d7486d --- /dev/null +++ b/src/Aspire.Cli/Commands/SelfCommand.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Aspire.Cli.Commands; + +internal sealed class SelfCommand : Command +{ + public SelfCommand(SelfUpdateCommand updateCommand) : base("self", "Manage the Aspire CLI itself") + { + ArgumentNullException.ThrowIfNull(updateCommand); + + Subcommands.Add(updateCommand); + } +} diff --git a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs new file mode 100644 index 00000000000..f352991fa13 --- /dev/null +++ b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs @@ -0,0 +1,250 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class SelfUpdateCommand : BaseCommand +{ + private readonly ILogger _logger; + private readonly ICliDownloader _cliDownloader; + + public SelfUpdateCommand( + ILogger logger, + ICliDownloader cliDownloader, + IInteractionService interactionService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext) + : base("update", "Updates the Aspire CLI to the latest version", features, updateNotifier, executionContext, interactionService) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(cliDownloader); + + _logger = logger; + _cliDownloader = cliDownloader; + + var qualityOption = new Option("--quality"); + qualityOption.Description = "Quality level to update to (release, staging, dev)"; + qualityOption.DefaultValueFactory = (result) => "release"; + Options.Add(qualityOption); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var quality = parseResult.GetValue("--quality") ?? "release"; + + try + { + // Get current executable path + var currentExePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(currentExePath)) + { + InteractionService.DisplayError("Unable to determine the current executable path."); + return ExitCodeConstants.InvalidCommand; + } + + InteractionService.DisplayMessage(":package:", $"Current CLI location: {currentExePath}"); + InteractionService.DisplayMessage(":arrow_up:", $"Updating to quality level: {quality}"); + + // Download the latest CLI + var archivePath = await _cliDownloader.DownloadLatestCliAsync(quality, cancellationToken); + + // Extract and update + await ExtractAndUpdateAsync(currentExePath, archivePath, cancellationToken); + + InteractionService.DisplaySuccess("Aspire CLI has been successfully updated!"); + InteractionService.DisplayMessage(":information:", "Run 'aspire --version' to verify the new version."); + + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update CLI"); + InteractionService.DisplayError($"Failed to update CLI: {ex.Message}"); + return ExitCodeConstants.InvalidCommand; + } + } + + private async Task ExtractAndUpdateAsync(string currentExePath, string archivePath, CancellationToken cancellationToken) + { + var installDir = Path.GetDirectoryName(currentExePath); + if (string.IsNullOrEmpty(installDir)) + { + throw new InvalidOperationException("Unable to determine installation directory."); + } + + var exeName = Path.GetFileName(currentExePath); + var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempExtractDir); + + // Extract archive + InteractionService.DisplayMessage(":package:", "Extracting new CLI..."); + await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); + + // Find the aspire executable in the extracted files + var newExePath = Path.Combine(tempExtractDir, exeName); + if (!File.Exists(newExePath)) + { + throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); + } + + // Backup current executable + var backupPath = $"{currentExePath}.old"; + InteractionService.DisplayMessage(":floppy_disk:", "Backing up current CLI..."); + _logger.LogDebug("Creating backup: {BackupPath}", backupPath); + + // Remove old backup if it exists + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + // Rename current executable to .old + File.Move(currentExePath, backupPath); + + try + { + // Copy new executable to install location + InteractionService.DisplayMessage(":wrench:", "Installing new CLI..."); + File.Copy(newExePath, currentExePath, overwrite: true); + + // On Unix systems, ensure the executable bit is set + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetExecutablePermission(currentExePath); + } + + // Test the new executable + _logger.LogDebug("Testing new CLI executable"); + if (!await TestNewExecutableAsync(currentExePath, cancellationToken)) + { + throw new InvalidOperationException("New CLI executable failed verification test."); + } + + // If we get here, the update was successful, remove the backup + _logger.LogDebug("Update successful, removing backup"); + File.Delete(backupPath); + } + catch + { + // If anything goes wrong, restore the backup + _logger.LogWarning("Update failed, restoring backup"); + if (File.Exists(backupPath)) + { + if (File.Exists(currentExePath)) + { + File.Delete(currentExePath); + } + File.Move(backupPath, currentExePath); + } + throw; + } + } + finally + { + // Clean up temp directories + CleanupDirectory(tempExtractDir); + CleanupDirectory(Path.GetDirectoryName(archivePath)!); + } + } + + private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(archivePath).ToLowerInvariant(); + + if (extension == ".zip" || archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); + } + else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); + await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); + } + else + { + throw new NotSupportedException($"Unsupported archive format: {archivePath}"); + } + } + + private static void SetExecutablePermission(string filePath) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"+x \"{filePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + process?.WaitForExit(); + } + catch + { + // Best effort, ignore failures + } + } + + private static async Task TestNewExecutableAsync(string exePath, CancellationToken cancellationToken) + { + try + { + var psi = new ProcessStartInfo + { + FileName = exePath, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is null) + { + return false; + } + + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + private void CleanupDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory); + } + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 987f2d8e391..6d9e47622ed 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -138,6 +138,7 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); // Template factories. @@ -155,6 +156,8 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs new file mode 100644 index 00000000000..8a477f86587 --- /dev/null +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -0,0 +1,189 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +/// +/// Handles downloading the Aspire CLI. +/// +internal interface ICliDownloader +{ + Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken); +} + +internal class CliDownloader( + ILogger logger, + IInteractionService interactionService) : ICliDownloader +{ + private const int ArchiveDownloadTimeoutSeconds = 600; + private const int ChecksumDownloadTimeoutSeconds = 120; + + private static readonly Dictionary s_qualityBaseUrls = new() + { + ["dev"] = "https://aka.ms/dotnet/9/aspire/daily", + ["staging"] = "https://aka.ms/dotnet/9/aspire/rc/daily", + ["release"] = "https://aka.ms/dotnet/9/aspire/ga/daily" + }; + + public async Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken) + { + if (!s_qualityBaseUrls.TryGetValue(quality, out var baseUrl)) + { + throw new ArgumentException($"Unsupported quality '{quality}'. Supported values are: dev, staging, release."); + } + + 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}"; + + // Create temp directory for download + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-download-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var archivePath = Path.Combine(tempDir, archiveFilename); + var checksumPath = Path.Combine(tempDir, checksumFilename); + + // Download archive + interactionService.DisplayMessage(":down_arrow:", $"Downloading Aspire CLI from: {archiveUrl}"); + logger.LogDebug("Downloading archive from {Url} to {Path}", archiveUrl, archivePath); + await DownloadFileAsync(archiveUrl, archivePath, ArchiveDownloadTimeoutSeconds, cancellationToken); + + // Download checksum + logger.LogDebug("Downloading checksum from {Url} to {Path}", checksumUrl, checksumPath); + await DownloadFileAsync(checksumUrl, checksumPath, ChecksumDownloadTimeoutSeconds, cancellationToken); + + // Validate checksum + interactionService.DisplayMessage(":check_mark:", "Validating downloaded file..."); + await ValidateChecksumAsync(archivePath, checksumPath, cancellationToken); + + interactionService.DisplaySuccess("Download completed successfully"); + return archivePath; + } + catch + { + // Clean up temp directory on failure + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to clean up temporary directory {TempDir}", tempDir); + } + throw; + } + } + + 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 httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(timeoutSeconds) + }; + + using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream, cancellationToken); + } + + 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}"); + } + } +} From 80d0262f0866a8d922660a4879064e7714ea9da6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:10:33 +0000 Subject: [PATCH 03/14] Add documentation for aspire self update command Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/README.md b/src/Aspire.Cli/README.md index 6f550e51d0c..fa7947f15e5 100644 --- a/src/Aspire.Cli/README.md +++ b/src/Aspire.Cli/README.md @@ -192,4 +192,41 @@ aspire config delete [options] - `-g, --global` - Delete the configuration value from the global settings file instead of the local settings file **Description:** -Manages CLI configuration settings. Configuration can be set locally (per project) or globally (user-wide). Local settings are stored in the current directory, while global settings are stored in `$HOME/.aspire/settings.json`. \ No newline at end of file +Manages CLI configuration settings. Configuration can be set locally (per project) or globally (user-wide). Local settings are stored in the current directory, while global settings are stored in `$HOME/.aspire/settings.json`. + +### self + +Manage the Aspire CLI itself. + +```cli +aspire self [command] [options] +``` + +**Subcommands:** + +#### update +Update the Aspire CLI to the latest version. + +```cli +aspire self update [options] +``` + +**Options:** +- `--quality ` - Quality level to update to (release, staging, dev) [default: release] + +**Description:** +Updates the Aspire CLI to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. + +**Quality Levels:** +- `release` - Latest stable release version (default) +- `staging` - Latest release candidate/staging version +- `dev` - Latest development build from main branch + +**Example:** +```cli +# Update to latest release +aspire self update + +# Update to latest development build +aspire self update --quality dev +``` \ No newline at end of file From c28cb39060e8455173575084af773fcdff2f247f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:25:55 +0000 Subject: [PATCH 04/14] Use File.GetUnixFileMode/SetUnixFileMode instead of chmod command Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/SelfUpdateCommand.cs | 25 ++++++++------------ 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs index f352991fa13..f832fb85542 100644 --- a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs +++ b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs @@ -185,23 +185,18 @@ private static async Task ExtractArchiveAsync(string archivePath, string destina private static void SetExecutablePermission(string filePath) { - try + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var psi = new ProcessStartInfo + try { - FileName = "chmod", - Arguments = $"+x \"{filePath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - process?.WaitForExit(); - } - catch - { - // Best effort, ignore failures + var mode = File.GetUnixFileMode(filePath); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(filePath, mode); + } + catch + { + // Best effort, ignore failures + } } } From 73e8a9e061055fbde96290583b907bfdbac6779f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:36:43 +0000 Subject: [PATCH 05/14] Display version after update and hide self command for dotnet tool Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 22 +++++++++++++++- src/Aspire.Cli/Commands/SelfUpdateCommand.cs | 27 ++++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index d8ed2b2a85d..01c85c43242 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -107,7 +107,13 @@ public RootCommand( Subcommands.Add(cacheCommand); Subcommands.Add(deployCommand); Subcommands.Add(updateCommand); - Subcommands.Add(selfCommand); + + // Only add self command if not running as a dotnet tool + if (!IsRunningAsDotNetTool()) + { + Subcommands.Add(selfCommand); + } + Subcommands.Add(extensionInternalCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) @@ -116,4 +122,18 @@ public RootCommand( } } + + 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); + } } diff --git a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs index f832fb85542..d585f245468 100644 --- a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs +++ b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs @@ -64,9 +64,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Extract and update await ExtractAndUpdateAsync(currentExePath, archivePath, cancellationToken); - InteractionService.DisplaySuccess("Aspire CLI has been successfully updated!"); - InteractionService.DisplayMessage(":information:", "Run 'aspire --version' to verify the new version."); - return 0; } catch (Exception ex) @@ -129,9 +126,10 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa SetExecutablePermission(currentExePath); } - // Test the new executable - _logger.LogDebug("Testing new CLI executable"); - if (!await TestNewExecutableAsync(currentExePath, cancellationToken)) + // Test the new executable and display its version + _logger.LogDebug("Testing new CLI executable and displaying version"); + var newVersion = await GetNewVersionAsync(currentExePath, cancellationToken); + if (newVersion is null) { throw new InvalidOperationException("New CLI executable failed verification test."); } @@ -200,7 +198,7 @@ private static void SetExecutablePermission(string filePath) } } - private static async Task TestNewExecutableAsync(string exePath, CancellationToken cancellationToken) + private async Task GetNewVersionAsync(string exePath, CancellationToken cancellationToken) { try { @@ -216,15 +214,24 @@ private static async Task TestNewExecutableAsync(string exePath, Cancellat using var process = Process.Start(psi); if (process is null) { - return false; + return null; } + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken); - return process.ExitCode == 0; + + if (process.ExitCode == 0) + { + var version = output.Trim(); + InteractionService.DisplaySuccess($"Updated to version: {version}"); + return version; + } + + return null; } catch { - return false; + return null; } } From 3791238d1f677ead2929f93c90f6e274d52296c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:18:30 +0000 Subject: [PATCH 06/14] Update quality names to match existing channels and add interactive prompt Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/SelfUpdateCommand.cs | 18 ++++++++++++++---- src/Aspire.Cli/README.md | 15 +++++++++------ src/Aspire.Cli/Utils/CliDownloader.cs | 4 ++-- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs index d585f245468..4d5c7a399ce 100644 --- a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs +++ b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs @@ -33,9 +33,8 @@ public SelfUpdateCommand( _logger = logger; _cliDownloader = cliDownloader; - var qualityOption = new Option("--quality"); - qualityOption.Description = "Quality level to update to (release, staging, dev)"; - qualityOption.DefaultValueFactory = (result) => "release"; + var qualityOption = new Option("--quality"); + qualityOption.Description = "Quality level to update to (stable, staging, daily)"; Options.Add(qualityOption); } @@ -43,7 +42,18 @@ public SelfUpdateCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var quality = parseResult.GetValue("--quality") ?? "release"; + var quality = parseResult.GetValue("--quality"); + + // If quality is not specified, prompt the user + if (string.IsNullOrEmpty(quality)) + { + var qualities = new[] { "stable", "staging", "daily" }; + quality = await InteractionService.PromptForSelectionAsync( + "Select the quality level to update to:", + qualities, + q => q, + cancellationToken); + } try { diff --git a/src/Aspire.Cli/README.md b/src/Aspire.Cli/README.md index fa7947f15e5..04e957d4d36 100644 --- a/src/Aspire.Cli/README.md +++ b/src/Aspire.Cli/README.md @@ -212,21 +212,24 @@ aspire self update [options] ``` **Options:** -- `--quality ` - Quality level to update to (release, staging, dev) [default: release] +- `--quality ` - Quality level to update to (stable, staging, daily) **Description:** -Updates the Aspire CLI to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. +Updates the Aspire CLI to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. If the quality level is not specified, an interactive prompt will appear to select from the available options. **Quality Levels:** -- `release` - Latest stable release version (default) +- `stable` - Latest stable release version - `staging` - Latest release candidate/staging version -- `dev` - Latest development build from main branch +- `daily` - Latest development build from main branch **Example:** ```cli -# Update to latest release +# Update with interactive quality selection aspire self update +# Update to latest stable release +aspire self update --quality stable + # Update to latest development build -aspire self update --quality dev +aspire self update --quality daily ``` \ No newline at end of file diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 8a477f86587..8ee96e4e12c 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -26,9 +26,9 @@ internal class CliDownloader( private static readonly Dictionary s_qualityBaseUrls = new() { - ["dev"] = "https://aka.ms/dotnet/9/aspire/daily", + ["daily"] = "https://aka.ms/dotnet/9/aspire/daily", ["staging"] = "https://aka.ms/dotnet/9/aspire/rc/daily", - ["release"] = "https://aka.ms/dotnet/9/aspire/ga/daily" + ["stable"] = "https://aka.ms/dotnet/9/aspire/ga/daily" }; public async Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken) From 7a17683bc755f04eeaf91f1a9183514026817674 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:15:57 +0000 Subject: [PATCH 07/14] Change command from 'aspire self update' to 'aspire update --self' Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 23 -- src/Aspire.Cli/Commands/SelfCommand.cs | 16 -- src/Aspire.Cli/Commands/SelfUpdateCommand.cs | 262 ----------------- src/Aspire.Cli/Commands/UpdateCommand.cs | 285 ++++++++++++++++++- src/Aspire.Cli/Program.cs | 2 - src/Aspire.Cli/README.md | 69 ++--- 6 files changed, 312 insertions(+), 345 deletions(-) delete mode 100644 src/Aspire.Cli/Commands/SelfCommand.cs delete mode 100644 src/Aspire.Cli/Commands/SelfUpdateCommand.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 01c85c43242..5c1ef692539 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -30,7 +30,6 @@ public RootCommand( CacheCommand cacheCommand, ExecCommand execCommand, UpdateCommand updateCommand, - SelfCommand selfCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -46,7 +45,6 @@ public RootCommand( ArgumentNullException.ThrowIfNull(deployCommand); ArgumentNullException.ThrowIfNull(updateCommand); ArgumentNullException.ThrowIfNull(execCommand); - ArgumentNullException.ThrowIfNull(selfCommand); ArgumentNullException.ThrowIfNull(extensionInternalCommand); ArgumentNullException.ThrowIfNull(featureFlags); ArgumentNullException.ThrowIfNull(interactionService); @@ -107,13 +105,6 @@ public RootCommand( Subcommands.Add(cacheCommand); Subcommands.Add(deployCommand); Subcommands.Add(updateCommand); - - // Only add self command if not running as a dotnet tool - if (!IsRunningAsDotNetTool()) - { - Subcommands.Add(selfCommand); - } - Subcommands.Add(extensionInternalCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) @@ -122,18 +113,4 @@ public RootCommand( } } - - 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); - } } diff --git a/src/Aspire.Cli/Commands/SelfCommand.cs b/src/Aspire.Cli/Commands/SelfCommand.cs deleted file mode 100644 index f4356d7486d..00000000000 --- a/src/Aspire.Cli/Commands/SelfCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; - -namespace Aspire.Cli.Commands; - -internal sealed class SelfCommand : Command -{ - public SelfCommand(SelfUpdateCommand updateCommand) : base("self", "Manage the Aspire CLI itself") - { - ArgumentNullException.ThrowIfNull(updateCommand); - - Subcommands.Add(updateCommand); - } -} diff --git a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs b/src/Aspire.Cli/Commands/SelfUpdateCommand.cs deleted file mode 100644 index 4d5c7a399ce..00000000000 --- a/src/Aspire.Cli/Commands/SelfUpdateCommand.cs +++ /dev/null @@ -1,262 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.Diagnostics; -using System.Formats.Tar; -using System.IO.Compression; -using System.Runtime.InteropServices; -using Aspire.Cli.Configuration; -using Aspire.Cli.Interaction; -using Aspire.Cli.Utils; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Commands; - -internal sealed class SelfUpdateCommand : BaseCommand -{ - private readonly ILogger _logger; - private readonly ICliDownloader _cliDownloader; - - public SelfUpdateCommand( - ILogger logger, - ICliDownloader cliDownloader, - IInteractionService interactionService, - IFeatures features, - ICliUpdateNotifier updateNotifier, - CliExecutionContext executionContext) - : base("update", "Updates the Aspire CLI to the latest version", features, updateNotifier, executionContext, interactionService) - { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(cliDownloader); - - _logger = logger; - _cliDownloader = cliDownloader; - - var qualityOption = new Option("--quality"); - qualityOption.Description = "Quality level to update to (stable, staging, daily)"; - Options.Add(qualityOption); - } - - protected override bool UpdateNotificationsEnabled => false; - - protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) - { - var quality = parseResult.GetValue("--quality"); - - // If quality is not specified, prompt the user - if (string.IsNullOrEmpty(quality)) - { - var qualities = new[] { "stable", "staging", "daily" }; - quality = await InteractionService.PromptForSelectionAsync( - "Select the quality level to update to:", - qualities, - q => q, - cancellationToken); - } - - try - { - // Get current executable path - var currentExePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(currentExePath)) - { - InteractionService.DisplayError("Unable to determine the current executable path."); - return ExitCodeConstants.InvalidCommand; - } - - InteractionService.DisplayMessage(":package:", $"Current CLI location: {currentExePath}"); - InteractionService.DisplayMessage(":arrow_up:", $"Updating to quality level: {quality}"); - - // Download the latest CLI - var archivePath = await _cliDownloader.DownloadLatestCliAsync(quality, cancellationToken); - - // Extract and update - await ExtractAndUpdateAsync(currentExePath, archivePath, cancellationToken); - - return 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update CLI"); - InteractionService.DisplayError($"Failed to update CLI: {ex.Message}"); - return ExitCodeConstants.InvalidCommand; - } - } - - private async Task ExtractAndUpdateAsync(string currentExePath, string archivePath, CancellationToken cancellationToken) - { - var installDir = Path.GetDirectoryName(currentExePath); - if (string.IsNullOrEmpty(installDir)) - { - throw new InvalidOperationException("Unable to determine installation directory."); - } - - var exeName = Path.GetFileName(currentExePath); - var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}"); - - try - { - Directory.CreateDirectory(tempExtractDir); - - // Extract archive - InteractionService.DisplayMessage(":package:", "Extracting new CLI..."); - await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); - - // Find the aspire executable in the extracted files - var newExePath = Path.Combine(tempExtractDir, exeName); - if (!File.Exists(newExePath)) - { - throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); - } - - // Backup current executable - var backupPath = $"{currentExePath}.old"; - InteractionService.DisplayMessage(":floppy_disk:", "Backing up current CLI..."); - _logger.LogDebug("Creating backup: {BackupPath}", backupPath); - - // Remove old backup if it exists - if (File.Exists(backupPath)) - { - File.Delete(backupPath); - } - - // Rename current executable to .old - File.Move(currentExePath, backupPath); - - try - { - // Copy new executable to install location - InteractionService.DisplayMessage(":wrench:", "Installing new CLI..."); - File.Copy(newExePath, currentExePath, overwrite: true); - - // On Unix systems, ensure the executable bit is set - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - SetExecutablePermission(currentExePath); - } - - // Test the new executable and display its version - _logger.LogDebug("Testing new CLI executable and displaying version"); - var newVersion = await GetNewVersionAsync(currentExePath, cancellationToken); - if (newVersion is null) - { - throw new InvalidOperationException("New CLI executable failed verification test."); - } - - // If we get here, the update was successful, remove the backup - _logger.LogDebug("Update successful, removing backup"); - File.Delete(backupPath); - } - catch - { - // If anything goes wrong, restore the backup - _logger.LogWarning("Update failed, restoring backup"); - if (File.Exists(backupPath)) - { - if (File.Exists(currentExePath)) - { - File.Delete(currentExePath); - } - File.Move(backupPath, currentExePath); - } - throw; - } - } - finally - { - // Clean up temp directories - CleanupDirectory(tempExtractDir); - CleanupDirectory(Path.GetDirectoryName(archivePath)!); - } - } - - private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) - { - var extension = Path.GetExtension(archivePath).ToLowerInvariant(); - - if (extension == ".zip" || archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); - } - else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) - { - await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); - await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); - } - else - { - throw new NotSupportedException($"Unsupported archive format: {archivePath}"); - } - } - - private static 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 - { - // Best effort, ignore failures - } - } - } - - private async Task GetNewVersionAsync(string exePath, CancellationToken cancellationToken) - { - try - { - var psi = new ProcessStartInfo - { - FileName = exePath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is null) - { - return null; - } - - 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 - { - return null; - } - } - - private void CleanupDirectory(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory); - } - } -} diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index fe5723a4ae9..400dd0b0a65 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -2,12 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; using Spectre.Console; namespace Aspire.Cli.Commands; @@ -17,24 +22,82 @@ internal sealed class UpdateCommand : BaseCommand private readonly IProjectLocator _projectLocator; private readonly IPackagingService _packagingService; private readonly IProjectUpdater _projectUpdater; + private readonly ILogger _logger; + private readonly ICliDownloader? _cliDownloader; - public UpdateCommand(IProjectLocator projectLocator, IPackagingService packagingService, IProjectUpdater projectUpdater, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext) : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService) + public UpdateCommand( + IProjectLocator projectLocator, + IPackagingService packagingService, + IProjectUpdater projectUpdater, + ILogger logger, + ICliDownloader? cliDownloader, + IInteractionService interactionService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext) + : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService) { ArgumentNullException.ThrowIfNull(projectLocator); ArgumentNullException.ThrowIfNull(packagingService); ArgumentNullException.ThrowIfNull(projectUpdater); + ArgumentNullException.ThrowIfNull(logger); _projectLocator = projectLocator; _packagingService = packagingService; _projectUpdater = projectUpdater; + _logger = logger; + _cliDownloader = cliDownloader; var projectOption = new Option("--project"); projectOption.Description = UpdateCommandStrings.ProjectArgumentDescription; Options.Add(projectOption); + + // Only add --self option if not running as dotnet tool + if (!IsRunningAsDotNetTool()) + { + var selfOption = new Option("--self"); + selfOption.Description = "Update the Aspire CLI itself to the latest version"; + Options.Add(selfOption); + + var qualityOption = new Option("--quality"); + qualityOption.Description = "Quality level to update to when using --self (stable, staging, daily)"; + Options.Add(qualityOption); + } + } + + 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("--self"); + + // If --self is specified, handle CLI self-update + if (isSelfUpdate) + { + if (_cliDownloader is null) + { + InteractionService.DisplayError("CLI self-update is not available in this environment."); + return ExitCodeConstants.InvalidCommand; + } + + return await ExecuteSelfUpdateAsync(parseResult, cancellationToken); + } + + // Otherwise, handle project update try { var passedAppHostProjectFile = parseResult.GetValue("--project"); @@ -63,4 +126,224 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return 0; } + + private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var quality = parseResult.GetValue("--quality"); + + // If quality is not specified, prompt the user + if (string.IsNullOrEmpty(quality)) + { + var qualities = new[] { "stable", "staging", "daily" }; + quality = await InteractionService.PromptForSelectionAsync( + "Select the quality level to update to:", + qualities, + q => q, + cancellationToken); + } + + try + { + // Get current executable path + var currentExePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(currentExePath)) + { + InteractionService.DisplayError("Unable to determine the current executable path."); + return ExitCodeConstants.InvalidCommand; + } + + InteractionService.DisplayMessage(":package:", $"Current CLI location: {currentExePath}"); + InteractionService.DisplayMessage(":arrow_up:", $"Updating to quality level: {quality}"); + + // Download the latest CLI + var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken); + + // Extract and update + await ExtractAndUpdateAsync(currentExePath, archivePath, cancellationToken); + + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update CLI"); + InteractionService.DisplayError($"Failed to update CLI: {ex.Message}"); + return ExitCodeConstants.InvalidCommand; + } + } + + private async Task ExtractAndUpdateAsync(string currentExePath, string archivePath, CancellationToken cancellationToken) + { + var installDir = Path.GetDirectoryName(currentExePath); + if (string.IsNullOrEmpty(installDir)) + { + throw new InvalidOperationException("Unable to determine installation directory."); + } + + var exeName = Path.GetFileName(currentExePath); + var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempExtractDir); + + // Extract archive + InteractionService.DisplayMessage(":package:", "Extracting new CLI..."); + await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); + + // Find the aspire executable in the extracted files + var newExePath = Path.Combine(tempExtractDir, exeName); + if (!File.Exists(newExePath)) + { + throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); + } + + // Backup current executable + var backupPath = $"{currentExePath}.old"; + InteractionService.DisplayMessage(":floppy_disk:", "Backing up current CLI..."); + _logger.LogDebug("Creating backup: {BackupPath}", backupPath); + + // Remove old backup if it exists + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + // Rename current executable to .old + File.Move(currentExePath, backupPath); + + try + { + // Copy new executable to install location + InteractionService.DisplayMessage(":wrench:", "Installing new CLI..."); + File.Copy(newExePath, currentExePath, overwrite: true); + + // On Unix systems, ensure the executable bit is set + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetExecutablePermission(currentExePath); + } + + // Test the new executable and display its version + _logger.LogDebug("Testing new CLI executable and displaying version"); + var newVersion = await GetNewVersionAsync(currentExePath, cancellationToken); + if (newVersion is null) + { + throw new InvalidOperationException("New CLI executable failed verification test."); + } + + // If we get here, the update was successful, remove the backup + _logger.LogDebug("Update successful, removing backup"); + File.Delete(backupPath); + } + catch + { + // If anything goes wrong, restore the backup + _logger.LogWarning("Update failed, restoring backup"); + if (File.Exists(backupPath)) + { + if (File.Exists(currentExePath)) + { + File.Delete(currentExePath); + } + File.Move(backupPath, currentExePath); + } + throw; + } + } + finally + { + // Clean up temp directories + CleanupDirectory(tempExtractDir); + CleanupDirectory(Path.GetDirectoryName(archivePath)!); + } + } + + private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(archivePath).ToLowerInvariant(); + + if (extension == ".zip" || archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); + } + else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); + await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); + } + else + { + throw new NotSupportedException($"Unsupported archive format: {archivePath}"); + } + } + + private static 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 + { + // Best effort, ignore failures + } + } + } + + private async Task GetNewVersionAsync(string exePath, CancellationToken cancellationToken) + { + try + { + var psi = new ProcessStartInfo + { + FileName = exePath, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is null) + { + return null; + } + + 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 + { + return null; + } + } + + private void CleanupDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory); + } + } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 6d9e47622ed..9dc8af31d41 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -156,8 +156,6 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/README.md b/src/Aspire.Cli/README.md index 04e957d4d36..2b6edc35744 100644 --- a/src/Aspire.Cli/README.md +++ b/src/Aspire.Cli/README.md @@ -134,9 +134,36 @@ Update integrations in the Aspire project. (Preview) aspire update [options] ``` +**Options:** +- `--project` - The path to the project file +- `--self` - Update the Aspire CLI itself to the latest version +- `--quality ` - Quality level to update to when using --self (stable, staging, daily) + **Description:** Updates Aspire integration packages to their latest compatible versions. Supports both traditional package management (PackageReference with Version) and Central Package Management (CPM) using Directory.Packages.props. The command automatically detects the package management approach used in the project and updates packages accordingly. +When using `--self`, the CLI will update itself to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. If the quality level is not specified with `--self`, an interactive prompt will appear to select from the available options. + +**Quality Levels (for --self):** +- `stable` - Latest stable release version +- `staging` - Latest release candidate/staging version +- `daily` - Latest development build from main branch + +**Example:** +```cli +# Update project integrations +aspire update + +# Update CLI with interactive quality selection +aspire update --self + +# Update CLI to latest stable release +aspire update --self --quality stable + +# Update CLI to latest development build +aspire update --self --quality daily +``` + ### config Manage configuration settings. @@ -192,44 +219,4 @@ aspire config delete [options] - `-g, --global` - Delete the configuration value from the global settings file instead of the local settings file **Description:** -Manages CLI configuration settings. Configuration can be set locally (per project) or globally (user-wide). Local settings are stored in the current directory, while global settings are stored in `$HOME/.aspire/settings.json`. - -### self - -Manage the Aspire CLI itself. - -```cli -aspire self [command] [options] -``` - -**Subcommands:** - -#### update -Update the Aspire CLI to the latest version. - -```cli -aspire self update [options] -``` - -**Options:** -- `--quality ` - Quality level to update to (stable, staging, daily) - -**Description:** -Updates the Aspire CLI to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. If the quality level is not specified, an interactive prompt will appear to select from the available options. - -**Quality Levels:** -- `stable` - Latest stable release version -- `staging` - Latest release candidate/staging version -- `daily` - Latest development build from main branch - -**Example:** -```cli -# Update with interactive quality selection -aspire self update - -# Update to latest stable release -aspire self update --quality stable - -# Update to latest development build -aspire self update --quality daily -``` \ No newline at end of file +Manages CLI configuration settings. Configuration can be set locally (per project) or globally (user-wide). Local settings are stored in the current directory, while global settings are stored in `$HOME/.aspire/settings.json`. \ No newline at end of file From b9a66c2973c36226020f4815043803435738f8d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 08:55:12 +0000 Subject: [PATCH 08/14] Use ShowStatusAsync for CLI download operation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Utils/CliDownloader.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 8ee96e4e12c..fb5f7dd4558 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -56,13 +56,17 @@ public async Task DownloadLatestCliAsync(string quality, CancellationTok var checksumPath = Path.Combine(tempDir, checksumFilename); // Download archive - interactionService.DisplayMessage(":down_arrow:", $"Downloading Aspire CLI from: {archiveUrl}"); - logger.LogDebug("Downloading archive from {Url} to {Path}", archiveUrl, archivePath); - await DownloadFileAsync(archiveUrl, archivePath, ArchiveDownloadTimeoutSeconds, cancellationToken); - - // Download checksum - logger.LogDebug("Downloading checksum from {Url} to {Path}", checksumUrl, checksumPath); - await DownloadFileAsync(checksumUrl, checksumPath, ChecksumDownloadTimeoutSeconds, cancellationToken); + _ = 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); + + // Download checksum + logger.LogDebug("Downloading checksum from {Url} to {Path}", checksumUrl, checksumPath); + await DownloadFileAsync(checksumUrl, checksumPath, ChecksumDownloadTimeoutSeconds, cancellationToken); + + return 0; // Return dummy value for ShowStatusAsync + }); // Validate checksum interactionService.DisplayMessage(":check_mark:", "Validating downloaded file..."); From b82a07f518b61272d0cb54d8fbcb118a16a8bd34 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 19 Oct 2025 11:43:58 +1100 Subject: [PATCH 09/14] Fix tests. --- .../TestServices/TestCliDownloader.cs | 34 +++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 8 +++++ 2 files changed, 42 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestCliDownloader.cs diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCliDownloader.cs b/tests/Aspire.Cli.Tests/TestServices/TestCliDownloader.cs new file mode 100644 index 00000000000..0d30214ab21 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestCliDownloader.cs @@ -0,0 +1,34 @@ +// 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; + +/// +/// Test implementation of ICliDownloader for unit tests. +/// +internal sealed class TestCliDownloader : ICliDownloader +{ + private readonly DirectoryInfo _tempDirectory; + + public TestCliDownloader(DirectoryInfo tempDirectory) + { + _tempDirectory = tempDirectory; + } + + public Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken) + { + // Ensure the directory exists + if (!_tempDirectory.Exists) + { + _tempDirectory.Create(); + } + + // Generate a unique filename for the test download + var filename = $"test-cli-download-{Guid.NewGuid():N}"; + var path = Path.Combine(_tempDirectory.FullName, filename); + + return Task.FromResult(path); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 6de6c9df8ca..c63c7b8dc5e 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -91,6 +91,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.CliExecutionContextFactory); services.AddSingleton(options.DiskCacheFactory); services.AddSingleton(options.CliHostEnvironmentFactory); + services.AddSingleton(options.CliDownloaderFactory); services.AddSingleton(); services.AddSingleton(options.ProjectUpdaterFactory); services.AddSingleton(); @@ -336,6 +337,13 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser }; public Func DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache(); + + public Func CliDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var executionContext = serviceProvider.GetRequiredService(); + var tmpDirectory = new DirectoryInfo(Path.Combine(executionContext.WorkingDirectory.FullName, "tmp")); + return new TestCliDownloader(tmpDirectory); + }; } internal sealed class TestOutputTextWriter : TextWriter From 586742bca2149ddfaf618c6581392f62a960e0c5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 19 Oct 2025 12:06:41 +1100 Subject: [PATCH 10/14] Update emojis. --- src/Aspire.Cli/Commands/UpdateCommand.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 400dd0b0a65..2826f1fed3c 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -152,8 +152,8 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella return ExitCodeConstants.InvalidCommand; } - InteractionService.DisplayMessage(":package:", $"Current CLI location: {currentExePath}"); - InteractionService.DisplayMessage(":arrow_up:", $"Updating to quality level: {quality}"); + InteractionService.DisplayMessage("package", $"Current CLI location: {currentExePath}"); + InteractionService.DisplayMessage("arrow_up", $"Updating to quality level: {quality}"); // Download the latest CLI var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken); @@ -187,7 +187,7 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa Directory.CreateDirectory(tempExtractDir); // Extract archive - InteractionService.DisplayMessage(":package:", "Extracting new CLI..."); + InteractionService.DisplayMessage("package", "Extracting new CLI..."); await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); // Find the aspire executable in the extracted files @@ -199,7 +199,7 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa // Backup current executable var backupPath = $"{currentExePath}.old"; - InteractionService.DisplayMessage(":floppy_disk:", "Backing up current CLI..."); + InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI..."); _logger.LogDebug("Creating backup: {BackupPath}", backupPath); // Remove old backup if it exists @@ -214,7 +214,7 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa try { // Copy new executable to install location - InteractionService.DisplayMessage(":wrench:", "Installing new CLI..."); + InteractionService.DisplayMessage("wrench", "Installing new CLI..."); File.Copy(newExePath, currentExePath, overwrite: true); // On Unix systems, ensure the executable bit is set From 2779e563f2a041f398e33d353e445946f7c89b06 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 19 Oct 2025 12:19:04 +1100 Subject: [PATCH 11/14] Emoji fixes. --- src/Aspire.Cli/Commands/UpdateCommand.cs | 93 ++++++++++++++++-------- src/Aspire.Cli/Utils/CliDownloader.cs | 2 +- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 2826f1fed3c..84c091d4b3a 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -144,7 +144,7 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella try { - // Get current executable path + // Get current executable path for display purposes only var currentExePath = Environment.ProcessPath; if (string.IsNullOrEmpty(currentExePath)) { @@ -153,13 +153,13 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella } InteractionService.DisplayMessage("package", $"Current CLI location: {currentExePath}"); - InteractionService.DisplayMessage("arrow_up", $"Updating to quality level: {quality}"); + InteractionService.DisplayMessage("up_arrow", $"Updating to quality level: {quality}"); // Download the latest CLI var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken); - // Extract and update - await ExtractAndUpdateAsync(currentExePath, archivePath, cancellationToken); + // Extract and update to $HOME/.aspire/bin + await ExtractAndUpdateAsync(archivePath, cancellationToken); return 0; } @@ -171,15 +171,20 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella } } - private async Task ExtractAndUpdateAsync(string currentExePath, string archivePath, CancellationToken cancellationToken) + private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken) { - var installDir = Path.GetDirectoryName(currentExePath); - if (string.IsNullOrEmpty(installDir)) + // Always install to $HOME/.aspire/bin + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(homeDir)) { - throw new InvalidOperationException("Unable to determine installation directory."); + throw new InvalidOperationException("Unable to determine home directory."); } - var exeName = Path.GetFileName(currentExePath); + var installDir = Path.Combine(homeDir, ".aspire", "bin"); + Directory.CreateDirectory(installDir); + + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire"; + var targetExePath = Path.Combine(installDir, exeName); var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}"); try @@ -197,43 +202,55 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); } - // Backup current executable - var backupPath = $"{currentExePath}.old"; - InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI..."); - _logger.LogDebug("Creating backup: {BackupPath}", backupPath); - - // Remove old backup if it exists - if (File.Exists(backupPath)) + // Backup current executable if it exists + var backupPath = $"{targetExePath}.old"; + if (File.Exists(targetExePath)) { - File.Delete(backupPath); - } + InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI..."); + _logger.LogDebug("Creating backup: {BackupPath}", backupPath); - // Rename current executable to .old - File.Move(currentExePath, backupPath); + // Remove old backup if it exists + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + // Rename current executable to .old + File.Move(targetExePath, backupPath); + } try { // Copy new executable to install location - InteractionService.DisplayMessage("wrench", "Installing new CLI..."); - File.Copy(newExePath, currentExePath, overwrite: true); + InteractionService.DisplayMessage("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(currentExePath); + SetExecutablePermission(targetExePath); } // Test the new executable and display its version _logger.LogDebug("Testing new CLI executable and displaying version"); - var newVersion = await GetNewVersionAsync(currentExePath, cancellationToken); + var newVersion = await GetNewVersionAsync(targetExePath, cancellationToken); if (newVersion is null) { throw new InvalidOperationException("New CLI executable failed verification test."); } // If we get here, the update was successful, remove the backup - _logger.LogDebug("Update successful, removing backup"); - File.Delete(backupPath); + if (File.Exists(backupPath)) + { + _logger.LogDebug("Update successful, removing backup"); + File.Delete(backupPath); + } + + // Display helpful message about PATH + if (!IsInPath(installDir)) + { + InteractionService.DisplayMessage("information", $"Note: {installDir} is not in your PATH. Add it to use the updated CLI globally."); + } } catch { @@ -241,11 +258,11 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa _logger.LogWarning("Update failed, restoring backup"); if (File.Exists(backupPath)) { - if (File.Exists(currentExePath)) + if (File.Exists(targetExePath)) { - File.Delete(currentExePath); + File.Delete(targetExePath); } - File.Move(backupPath, currentExePath); + File.Move(backupPath, targetExePath); } throw; } @@ -258,6 +275,24 @@ private async Task ExtractAndUpdateAsync(string currentExePath, string archivePa } } + private static bool IsInPath(string directory) + { + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) + { + return false; + } + + var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':'; + var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries); + + return paths.Any(p => + string.Equals(Path.GetFullPath(p.Trim()), Path.GetFullPath(directory), + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)); + } + private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) { var extension = Path.GetExtension(archivePath).ToLowerInvariant(); diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index fb5f7dd4558..49f8c54e872 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -69,7 +69,7 @@ public async Task DownloadLatestCliAsync(string quality, CancellationTok }); // Validate checksum - interactionService.DisplayMessage(":check_mark:", "Validating downloaded file..."); + interactionService.DisplayMessage("check_mark", "Validating downloaded file..."); await ValidateChecksumAsync(archivePath, checksumPath, cancellationToken); interactionService.DisplaySuccess("Download completed successfully"); From 38a588084b2642a3be4b85e7cbb31299b8895c13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Oct 2025 01:28:44 +0000 Subject: [PATCH 12/14] Move CLI download URLs from CliDownloader to PackagingService Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackageChannel.cs | 7 ++--- src/Aspire.Cli/Packaging/PackagingService.cs | 6 ++--- src/Aspire.Cli/Utils/CliDownloader.cs | 28 ++++++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 882076268e3..f2f2b135908 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -7,13 +7,14 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; public PackageMapping[]? Mappings { get; } = mappings; public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; + public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { @@ -151,9 +152,9 @@ public async Task> GetPackagesAsync(string packageId, return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index fb027f58713..de2e91e98dc 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -22,13 +22,13 @@ public Task> GetChannelsAsync(CancellationToken canc var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Stable, new[] { new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache); + }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache); + }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily"); var prPackageChannels = new List(); @@ -80,7 +80,7 @@ public Task> GetChannelsAsync(CancellationToken canc { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: true); + }, nuGetPackageCache, configureGlobalPackagesFolder: true, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily"); return stagingChannel; } diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 49f8c54e872..dd032c3bf97 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using Aspire.Cli.Interaction; +using Aspire.Cli.Packaging; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; @@ -14,30 +15,35 @@ namespace Aspire.Cli.Utils; /// internal interface ICliDownloader { - Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken); + Task DownloadLatestCliAsync(string channelName, CancellationToken cancellationToken); } internal class CliDownloader( ILogger logger, - IInteractionService interactionService) : ICliDownloader + IInteractionService interactionService, + IPackagingService packagingService) : ICliDownloader { private const int ArchiveDownloadTimeoutSeconds = 600; private const int ChecksumDownloadTimeoutSeconds = 120; - private static readonly Dictionary s_qualityBaseUrls = new() + public async Task DownloadLatestCliAsync(string channelName, CancellationToken cancellationToken) { - ["daily"] = "https://aka.ms/dotnet/9/aspire/daily", - ["staging"] = "https://aka.ms/dotnet/9/aspire/rc/daily", - ["stable"] = "https://aka.ms/dotnet/9/aspire/ga/daily" - }; + // Get the channel information from PackagingService + var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase)); + + if (channel is null) + { + throw new ArgumentException($"Unsupported channel '{channelName}'. Available channels: {string.Join(", ", channels.Select(c => c.Name))}"); + } - public async Task DownloadLatestCliAsync(string quality, CancellationToken cancellationToken) - { - if (!s_qualityBaseUrls.TryGetValue(quality, out var baseUrl)) + if (string.IsNullOrEmpty(channel.CliDownloadBaseUrl)) { - throw new ArgumentException($"Unsupported quality '{quality}'. Supported values are: dev, staging, release."); + throw new InvalidOperationException($"Channel '{channelName}' does not support CLI downloads."); } + var baseUrl = channel.CliDownloadBaseUrl; + var (os, arch) = DetectPlatform(); var runtimeIdentifier = $"{os}-{arch}"; var extension = os == "win" ? "zip" : "tar.gz"; From 55537bcc1cb97684cd3f6d3f43249713044ea087 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 19 Oct 2025 13:12:32 +1100 Subject: [PATCH 13/14] PR feedback. --- src/Aspire.Cli/Commands/UpdateCommand.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 84c091d4b3a..2a4d6309635 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -295,9 +295,7 @@ private static bool IsInPath(string directory) private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) { - var extension = Path.GetExtension(archivePath).ToLowerInvariant(); - - if (extension == ".zip" || archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); } From a34c8cc41bf9d511703186644487216775d2d324 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:44:11 +0000 Subject: [PATCH 14/14] Apply PR feedback: use Directory.CreateTempSubdirectory, singleton HttpClient, Path.PathSeparator, and log warnings Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/UpdateCommand.cs | 11 +++++------ src/Aspire.Cli/Utils/CliDownloader.cs | 15 +++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 2a4d6309635..5eb494b88cb 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -185,11 +185,10 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire"; var targetExePath = Path.Combine(installDir, exeName); - var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}"); + var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName; try { - Directory.CreateDirectory(tempExtractDir); // Extract archive InteractionService.DisplayMessage("package", "Extracting new CLI..."); @@ -283,7 +282,7 @@ private static bool IsInPath(string directory) return false; } - var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':'; + var pathSeparator = Path.PathSeparator; var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries); return paths.Any(p => @@ -311,7 +310,7 @@ private static async Task ExtractArchiveAsync(string archivePath, string destina } } - private static void SetExecutablePermission(string filePath) + private void SetExecutablePermission(string filePath) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -321,9 +320,9 @@ private static void SetExecutablePermission(string filePath) mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; File.SetUnixFileMode(filePath, mode); } - catch + catch (Exception ex) { - // Best effort, ignore failures + _logger.LogWarning(ex, "Failed to set executable permission on {FilePath}", filePath); } } } diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index dd032c3bf97..4d8b86d4287 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -25,6 +25,8 @@ internal class CliDownloader( { private const int ArchiveDownloadTimeoutSeconds = 600; private const int ChecksumDownloadTimeoutSeconds = 120; + + private static readonly HttpClient s_httpClient = new(); public async Task DownloadLatestCliAsync(string channelName, CancellationToken cancellationToken) { @@ -53,8 +55,7 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio var checksumUrl = $"{baseUrl}/{checksumFilename}"; // Create temp directory for download - var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-download-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-download").FullName; try { @@ -170,16 +171,14 @@ private static string DetectArchitecture() private static async Task DownloadFileAsync(string url, string outputPath, int timeoutSeconds, CancellationToken cancellationToken) { - using var httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(timeoutSeconds) - }; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); - using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + 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, cancellationToken); + await response.Content.CopyToAsync(fileStream, cts.Token); } private static async Task ValidateChecksumAsync(string archivePath, string checksumPath, CancellationToken cancellationToken)