-
Notifications
You must be signed in to change notification settings - Fork 714
Add aspire update --self command for CLI self-updates
#12118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
f5635be
e544275
80d0262
c28cb39
73e8a9e
3791238
7a17683
b9a66c2
b82a07f
586742b
2779e56
38a5880
55537bc
a34c8cc
9d8b5a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<UpdateCommand> _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<UpdateCommand> 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<FileInfo?>("--project"); | ||||||
| projectOption.Description = UpdateCommandStrings.ProjectArgumentDescription; | ||||||
| Options.Add(projectOption); | ||||||
|
|
||||||
| // Only add --self option if not running as dotnet tool | ||||||
| if (!IsRunningAsDotNetTool()) | ||||||
| { | ||||||
| var selfOption = new Option<bool>("--self"); | ||||||
| selfOption.Description = "Update the Aspire CLI itself to the latest version"; | ||||||
| Options.Add(selfOption); | ||||||
|
|
||||||
| var qualityOption = new Option<string?>("--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<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) | ||||||
| { | ||||||
| var isSelfUpdate = parseResult.GetValue<bool>("--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<FileInfo?>("--project"); | ||||||
|
|
@@ -63,4 +126,257 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell | |||||
|
|
||||||
| return 0; | ||||||
| } | ||||||
|
|
||||||
| private async Task<int> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken) | ||||||
| { | ||||||
| var quality = parseResult.GetValue<string?>("--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 for display purposes only | ||||||
| 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("up_arrow", $"Updating to quality level: {quality}"); | ||||||
|
|
||||||
| // Download the latest CLI | ||||||
| var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken); | ||||||
|
|
||||||
| // Extract and update to $HOME/.aspire/bin | ||||||
| await ExtractAndUpdateAsync(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 archivePath, CancellationToken cancellationToken) | ||||||
| { | ||||||
| // Always install to $HOME/.aspire/bin | ||||||
| var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | ||||||
| if (string.IsNullOrEmpty(homeDir)) | ||||||
| { | ||||||
| throw new InvalidOperationException("Unable to determine home directory."); | ||||||
| } | ||||||
|
|
||||||
| 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 | ||||||
| { | ||||||
| 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 if it exists | ||||||
| var backupPath = $"{targetExePath}.old"; | ||||||
| if (File.Exists(targetExePath)) | ||||||
| { | ||||||
| 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(targetExePath, backupPath); | ||||||
| } | ||||||
|
|
||||||
| try | ||||||
| { | ||||||
| // Copy new executable to install location | ||||||
| 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(targetExePath); | ||||||
| } | ||||||
|
|
||||||
| // Test the new executable and display its version | ||||||
| _logger.LogDebug("Testing new CLI executable and displaying version"); | ||||||
| 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 | ||||||
| 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 | ||||||
| { | ||||||
| // If anything goes wrong, restore the backup | ||||||
| _logger.LogWarning("Update failed, restoring backup"); | ||||||
| if (File.Exists(backupPath)) | ||||||
| { | ||||||
| if (File.Exists(targetExePath)) | ||||||
| { | ||||||
| File.Delete(targetExePath); | ||||||
| } | ||||||
| File.Move(backupPath, targetExePath); | ||||||
| } | ||||||
| throw; | ||||||
| } | ||||||
| } | ||||||
| finally | ||||||
| { | ||||||
| // Clean up temp directories | ||||||
| CleanupDirectory(tempExtractDir); | ||||||
| CleanupDirectory(Path.GetDirectoryName(archivePath)!); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private static bool IsInPath(string directory) | ||||||
| { | ||||||
| var pathEnv = Environment.GetEnvironmentVariable("PATH"); | ||||||
| if (string.IsNullOrEmpty(pathEnv)) | ||||||
| { | ||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':'; | ||||||
|
||||||
| var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':'; | |
| var pathSeparator = Path.PathSeparator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to use Path.PathSeparator for cross-platform compatibility. (commit: pending)
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Log a warning here if we are unable to make it executable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added warning logging when unable to set executable permission, including the exception details and file path. (commit: pending)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO any exception thrown by GetUnixFileMode or SetUnixFileMode should be at least logged as a warning, if it does not cause the update to be rolled back entirely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot implement this feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use Directory.CreateTempSubdirectory instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to use
Directory.CreateTempSubdirectory()for automatic cleanup. (commit: pending)